mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
345e60b7a3 | ||
|
|
b9fb6c9d35 | ||
|
|
5d0ccd8867 | ||
|
|
b36f484f3b | ||
|
|
d2f7f986c7 | ||
|
|
9137d03b9f | ||
|
|
f6c0d13a8b | ||
|
|
c472b4e33a | ||
|
|
f6c3fc6b50 | ||
|
|
8ba146495e | ||
|
|
68c46fefb2 | ||
|
|
eddda41559 | ||
|
|
5945fdc2ef | ||
|
|
d110dcbdcf | ||
|
|
ebc0aa8a4e | ||
|
|
d4113b464a | ||
|
|
753dfe1fb3 | ||
|
|
013cccaac5 | ||
|
|
504f8ffbeb | ||
|
|
81d1eb2553 | ||
|
|
c9327b0e92 | ||
|
|
36aa236d82 | ||
|
|
3c37aa4449 | ||
|
|
f1bf3cbe64 | ||
|
|
17a9356040 | ||
|
|
97c799bf2c | ||
|
|
99c02daec7 | ||
|
|
4cb792641d | ||
|
|
fbc2950854 | ||
|
|
08e0bdb437 | ||
|
|
73dcab4de1 | ||
|
|
00594a14ed | ||
|
|
52d5bab0f5 | ||
|
|
a41f4c56f7 | ||
|
|
8ea83962d6 | ||
|
|
9d97cc6e4c | ||
|
|
023579de9a | ||
|
|
214d99f1c3 | ||
|
|
d6fc6b9b10 | ||
|
|
cbdab93f92 | ||
|
|
abf37f0687 | ||
|
|
e993a43399 | ||
|
|
bc79969fd0 | ||
|
|
801f9ed403 | ||
|
|
3d9f6f8f58 | ||
|
|
c2676fb620 | ||
|
|
cdb4ab23e9 | ||
|
|
b3217f2d9c | ||
|
|
7b61385ad5 | ||
|
|
b5c86e74ad | ||
|
|
a3031a7eb9 | ||
|
|
7c49a0535f | ||
|
|
797e5b0794 | ||
|
|
bc6cd78c86 | ||
|
|
98a5b40be5 | ||
|
|
87c174fe0f | ||
|
|
0cbb45e4ad | ||
|
|
b11226af94 | ||
|
|
27dc5b5e19 | ||
|
|
d892b83110 | ||
|
|
c28984524a | ||
|
|
5d4fb63baa | ||
|
|
ca2574d1e5 | ||
|
|
758038e278 | ||
|
|
cb803b1a24 | ||
|
|
c985af5722 | ||
|
|
1ede54acbb | ||
|
|
a73ca6a95d | ||
|
|
25f4095276 | ||
|
|
d242c133dd | ||
|
|
da3e8044b7 | ||
|
|
b6d5939fbf | ||
|
|
d23ef88647 | ||
|
|
f374f130de | ||
|
|
b0c2c8611b | ||
|
|
6f6f56ce64 | ||
|
|
4b28ec4b3a | ||
|
|
d189084c15 | ||
|
|
975d9540bc | ||
|
|
212bfba950 | ||
|
|
619e32cd83 | ||
|
|
ce9ff53ae1 | ||
|
|
d13fb33884 | ||
|
|
253e91250f | ||
|
|
b23668372b | ||
|
|
5b847c136e | ||
|
|
0d304a793d | ||
|
|
906e1a1a19 | ||
|
|
87f46f1e02 | ||
|
|
56b4be1fe1 | ||
|
|
a08dcf05f1 | ||
|
|
460882f6c1 | ||
|
|
71fc94ba63 | ||
|
|
b587d834bc | ||
|
|
9b63e022ed | ||
|
|
324cbd76ee | ||
|
|
ceab13f672 | ||
|
|
10e17ed239 | ||
|
|
4f84479513 | ||
|
|
216ad4ed32 | ||
|
|
85e4555322 | ||
|
|
a90e443a88 | ||
|
|
a9c54b5f91 | ||
|
|
6d21435aab | ||
|
|
9c3bb11a66 | ||
|
|
d983080584 | ||
|
|
852b96009c | ||
|
|
c82617d857 | ||
|
|
6f19256187 | ||
|
|
479ede334b | ||
|
|
e1349255de | ||
|
|
4a600dc01d | ||
|
|
315256ce96 | ||
|
|
bbead20723 | ||
|
|
bb77c1c0bb | ||
|
|
2a2fb9ad55 | ||
|
|
ea5b66cdae | ||
|
|
1a978c7ee3 | ||
|
|
cfd458f463 | ||
|
|
61b8277822 | ||
|
|
53c8036e34 | ||
|
|
f5c8ce5109 | ||
|
|
17b11f19a3 | ||
|
|
2993279d78 | ||
|
|
b95b7e3781 | ||
|
|
c57200132e | ||
|
|
f6d9921581 | ||
|
|
3aada673d5 | ||
|
|
1456070044 | ||
|
|
883797a51d | ||
|
|
1b93512b0a | ||
|
|
7c99262a79 | ||
|
|
d10ecb88bb | ||
|
|
bb0f8e1a9a | ||
|
|
8be1ace9a1 | ||
|
|
80d6879d2a | ||
|
|
0b8dfaa5b5 | ||
|
|
3e25be1e9d | ||
|
|
4e550d14af | ||
|
|
eab33559ca | ||
|
|
83076a0770 | ||
|
|
db65b3140a | ||
|
|
70b703df8b | ||
|
|
00070a1bd2 | ||
|
|
c7bf419ac4 | ||
|
|
850f9f081a | ||
|
|
1110b6b047 | ||
|
|
3c99adc01c | ||
|
|
55cec9a16f | ||
|
|
c36c7c6440 | ||
|
|
7a4e0ea213 | ||
|
|
5a7e1c0ae5 | ||
|
|
9c3b079620 | ||
|
|
c75fff6833 | ||
|
|
6adf2851ba | ||
|
|
c6e1b9c629 | ||
|
|
09d7bad4a6 | ||
|
|
4e0bc1552a | ||
|
|
205d626cd6 | ||
|
|
dee2e5ecf8 | ||
|
|
995f857b08 | ||
|
|
073af2d3b4 | ||
|
|
4b0eaff99c | ||
|
|
2aac56c97a | ||
|
|
67f6880d32 | ||
|
|
1f2816e6fb | ||
|
|
50565fe312 | ||
|
|
6332da52e6 | ||
|
|
fa06d65046 | ||
|
|
1ce27a2543 | ||
|
|
406a4b3448 | ||
|
|
c0259ac409 | ||
|
|
d474ae24d3 | ||
|
|
62b06c0f56 | ||
|
|
af6851562c | ||
|
|
cb62a6a659 | ||
|
|
7411f7324a | ||
|
|
b3467e97b7 | ||
|
|
5b83e774e6 | ||
|
|
9cc2ca1010 | ||
|
|
77aaefa3c3 | ||
|
|
7a09950312 | ||
|
|
2244eb4bf0 | ||
|
|
5f9907adf7 | ||
|
|
64eeae78a8 | ||
|
|
a053104b13 | ||
|
|
d2ea1adcb0 | ||
|
|
20d86905c0 | ||
|
|
5fba6436ab | ||
|
|
dea6d68228 | ||
|
|
2ac9547f6a | ||
|
|
49529af813 | ||
|
|
54eabf49f0 | ||
|
|
16ad50ca4d | ||
|
|
49d09d0420 | ||
|
|
50e613748f | ||
|
|
69d76d2d44 | ||
|
|
65d9026f01 | ||
|
|
5b6ad26b2e | ||
|
|
59a132e536 | ||
|
|
9ec9de519e | ||
|
|
54c631eb40 | ||
|
|
ef96241743 | ||
|
|
0b51c3a5fa | ||
|
|
9ccdd53bd8 | ||
|
|
8e6730a5ce | ||
|
|
a2f4c3088a | ||
|
|
ecb65a29c1 | ||
|
|
b4d8b9ad37 | ||
|
|
d3cde0e373 | ||
|
|
28667067ca | ||
|
|
c469ef02dd | ||
|
|
f48990e31f | ||
|
|
2a0733968e | ||
|
|
cc7c90c012 | ||
|
|
02a54ce8bc | ||
|
|
25256321b2 | ||
|
|
370e3e90b0 | ||
|
|
c0c449ecc2 | ||
|
|
4f9cfcf46d | ||
|
|
b05ff9814e | ||
|
|
fa34e83474 | ||
|
|
1c73dd5efa | ||
|
|
8f66914dfb | ||
|
|
424a764a9c | ||
|
|
53ef315f50 | ||
|
|
9e3c15c9e8 | ||
|
|
240fb2ca7d | ||
|
|
4657df2900 | ||
|
|
a3aa0b9988 | ||
|
|
c33ed61220 | ||
|
|
d7cf3f82ee | ||
|
|
1ec492725c | ||
|
|
03d9dc36ec | ||
|
|
f0ffa1ad8a | ||
|
|
528d269dde | ||
|
|
5a642c4c06 | ||
|
|
867f382cc0 | ||
|
|
203411a875 | ||
|
|
9833c6c7bb | ||
|
|
4af0200161 | ||
|
|
d27ec5f031 | ||
|
|
9482b66846 | ||
|
|
dcf35a6930 | ||
|
|
e526fe79eb | ||
|
|
ad61d29f08 | ||
|
|
2485267666 | ||
|
|
46f4fbe585 | ||
|
|
a7a99eec05 | ||
|
|
bad960d786 | ||
|
|
a3110dbddc | ||
|
|
01c95d79c1 | ||
|
|
2b7cbc41f3 | ||
|
|
8bf1bffd70 | ||
|
|
df163a26de | ||
|
|
a98f8448d6 | ||
|
|
541892a59e | ||
|
|
8dcb6528ac | ||
|
|
210f9a75ed | ||
|
|
50e3a3bbfe | ||
|
|
7dd238a48a | ||
|
|
8a157947a6 | ||
|
|
5e309250f5 | ||
|
|
0875aefacf | ||
|
|
083dcc494e | ||
|
|
e5105b1bb2 | ||
|
|
e998a6d95a | ||
|
|
98f045a70f | ||
|
|
419f7cf657 | ||
|
|
9bef0791c4 | ||
|
|
15d667f653 | ||
|
|
939ae6f892 | ||
|
|
0cd36e57d6 | ||
|
|
5acb92e696 | ||
|
|
10a90c7d48 | ||
|
|
f0c5686b4a | ||
|
|
cae914b21b | ||
|
|
344083a3a9 | ||
|
|
36ed40f628 | ||
|
|
c650c4a1e0 | ||
|
|
f50c3b40d5 | ||
|
|
c1318f7efc | ||
|
|
615947b4d2 | ||
|
|
7c0a9fcd9a | ||
|
|
72572c9397 | ||
|
|
0e3874f35f | ||
|
|
db8a289517 | ||
|
|
698f0b91fc | ||
|
|
fb08a63e6f | ||
|
|
a320aba4da | ||
|
|
088e26ce4f | ||
|
|
e273159ecb | ||
|
|
f0ac53ffff | ||
|
|
4a364ce766 | ||
|
|
6c4cd27aa0 | ||
|
|
861578a417 | ||
|
|
7f62aa79d0 | ||
|
|
7223d51690 | ||
|
|
7eabd7f4df | ||
|
|
88b81dcd12 | ||
|
|
5d6b3d55cc | ||
|
|
a5f4f1be6e | ||
|
|
ac329cf538 | ||
|
|
fafd6a09a0 | ||
|
|
759029107a | ||
|
|
802484c1bb | ||
|
|
4dfb485c87 | ||
|
|
57c1812547 | ||
|
|
c261699f3e | ||
|
|
039deca865 | ||
|
|
f8a18dd1ee | ||
|
|
de93fe166b | ||
|
|
1956484295 | ||
|
|
f76604aca5 | ||
|
|
1f01d3e7be | ||
|
|
eae1a84832 | ||
|
|
17baebdbf2 | ||
|
|
023bd74f12 | ||
|
|
021ce4d03d | ||
|
|
761c0f79fb | ||
|
|
5e63936dda | ||
|
|
a7134e2dae | ||
|
|
1289c53941 | ||
|
|
b4a36a42cd | ||
|
|
ea4d9b3764 | ||
|
|
5c2819a70c | ||
|
|
83eb7a1b65 | ||
|
|
8976bb99f5 | ||
|
|
40b5ef594a | ||
|
|
5c6e2b8cad | ||
|
|
8083696469 | ||
|
|
c07b572abb | ||
|
|
0972a41962 | ||
|
|
aeae449abf | ||
|
|
fa6cc13c86 | ||
|
|
3e57b006d2 | ||
|
|
949c1f9176 | ||
|
|
2bcd0aa858 | ||
|
|
8ba8a054a6 | ||
|
|
08301d8664 | ||
|
|
c36ffa5ffd | ||
|
|
0d02215f71 | ||
|
|
dadea4b90c | ||
|
|
9c89142c47 | ||
|
|
e055e0bd53 | ||
|
|
14c8730435 | ||
|
|
1410e3a952 | ||
|
|
1e558de5e1 | ||
|
|
dab2414a92 | ||
|
|
03bb1e0552 | ||
|
|
1eab06c51d | ||
|
|
d217c91a30 | ||
|
|
b09cbb4bb5 | ||
|
|
d6d0ce515f | ||
|
|
db34c33c42 | ||
|
|
2024454f5b | ||
|
|
3718def8b4 | ||
|
|
094071b0ba | ||
|
|
ca8d397653 | ||
|
|
6ac44397d0 | ||
|
|
d6c4d7cf25 | ||
|
|
04ae651b15 | ||
|
|
ef6e2b0f88 | ||
|
|
b7271ea4c2 | ||
|
|
b5e835a0c6 | ||
|
|
a7df5a8d30 | ||
|
|
883050e0b4 | ||
|
|
b6417ed780 | ||
|
|
affb16fc0e | ||
|
|
56409dd1d7 | ||
|
|
1af0193636 | ||
|
|
53e766318c | ||
|
|
c5269791af | ||
|
|
fb5ffd053d | ||
|
|
0c04e7b79f | ||
|
|
dc5fab6f9a | ||
|
|
128c48947e | ||
|
|
4cab02e88d | ||
|
|
a360687264 | ||
|
|
6a677b6fe1 | ||
|
|
5c418d5618 | ||
|
|
e6e782dec8 | ||
|
|
ab7d20d7d3 | ||
|
|
ec4c2cdd56 | ||
|
|
88d3e13b52 | ||
|
|
7bf70d1973 | ||
|
|
8eae2dc69d | ||
|
|
aa44a08e9e | ||
|
|
6071abca49 |
@@ -1,7 +1,7 @@
|
||||
version: '3.7'
|
||||
services:
|
||||
# Update this to the name of the service you want to work with in your docker-compose.yml file
|
||||
app:
|
||||
multi-scrobbler:
|
||||
# Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
|
||||
# folder. Note that the path of the Dockerfile and context is relative to the *primary*
|
||||
# docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"dockerComposeFile": [
|
||||
"compose.yml"
|
||||
// uncomment to add kasm/discord service
|
||||
//"../deploy/docker-compose.discord.yml"
|
||||
],
|
||||
|
||||
"service": "app",
|
||||
"service": "multi-scrobbler",
|
||||
|
||||
"workspaceFolder": "/workspaces",
|
||||
|
||||
|
||||
+2
-1
@@ -27,4 +27,5 @@ build
|
||||
.vscode
|
||||
**/act/*
|
||||
tmp-*
|
||||
.env
|
||||
.env
|
||||
*.env
|
||||
@@ -7,7 +7,9 @@ body:
|
||||
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/)
|
||||
- label: I have [read the Help docs](https://foxxmd.github.io/multi-scrobbler/help/)
|
||||
required: true
|
||||
- label: I have [checked the FAQ](https://foxxmd.github.io/multi-scrobbler/FAQ/) and [documentation](https://foxxmd.github.io/multi-scrobbler/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
|
||||
@@ -23,6 +25,16 @@ body:
|
||||
placeholder: The app should have scrobbled from Spotify but instead it crashed with an error about undefined properties, etc...
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: What configuration types are you using for multi-scrobbler? Check all that apply.
|
||||
options:
|
||||
- label: "[Environmental Variables (ENV)](https://foxxmd.github.io/multi-scrobbler/configuration/?configType=env#configuration-types)"
|
||||
required: false
|
||||
- label: "[Individual](https://foxxmd.github.io/multi-scrobbler/configuration/?configType=file#configuration-types) Config Files"
|
||||
required: false
|
||||
- label: "[All-In-One](https://foxxmd.github.io/multi-scrobbler/configuration/?configType=aio#configuration-types) Config File"
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
@@ -52,8 +64,8 @@ body:
|
||||
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).
|
||||
Your logs will be automatically formatted into code. Do not use fenced code blocks (backticks).
|
||||
If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/configuration#debug-mode).
|
||||
render: shell
|
||||
placeholder: |
|
||||
[2025-02-14 12:47:57.170 -0500] VERBOSE: [Init] Config Dir ENV: undefined -> Resolved: CWD/config
|
||||
@@ -61,6 +73,22 @@ body:
|
||||
[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
|
||||
id: playDebug
|
||||
attributes:
|
||||
label: Play Debug Data
|
||||
description: |
|
||||
If relevent, please copy and paste all [**Debug Data**](https://foxxmd.github.io/multi-scrobbler/help#copy-play-debug-data) related to the Plays you are having issues with.
|
||||
Your data will be automatically formatted into code. Do not use fenced code blocks (backticks) in the text area below.
|
||||
render: shell
|
||||
placeholder: |
|
||||
{
|
||||
"input": {
|
||||
"addedAt": 1690250024,
|
||||
"art": "/library/metadata/186983/art/1698213665",
|
||||
"duration": 243983,
|
||||
"grandparentArt": "/library/metadata/186983/art/1698213665",
|
||||
...
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
|
||||
@@ -7,7 +7,7 @@ body:
|
||||
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/)
|
||||
- label: I have checked the [documentation](https://foxxmd.github.io/multi-scrobbler/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
|
||||
|
||||
@@ -7,7 +7,7 @@ body:
|
||||
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/)
|
||||
- label: I have checked the [documentation](https://foxxmd.github.io/multi-scrobbler/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
|
||||
|
||||
@@ -73,6 +73,7 @@ web_modules/
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
*.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
@@ -127,6 +128,14 @@ config/*.cache
|
||||
|
||||
src/**/**.js
|
||||
src/**/**.js.map
|
||||
src/backend/common/schema/*.json
|
||||
!src/backend/common/schema/aio-client.json
|
||||
!src/backend/common/schema/aio-source.json
|
||||
!src/backend/common/schema/aio.json
|
||||
!src/backend/common/schema/client.json
|
||||
!src/backend/common/schema/source.json
|
||||
docsite/static/schemas/*.json
|
||||
|
||||
|
||||
*.bak
|
||||
*.p8
|
||||
|
||||
Vendored
+2
-1
@@ -4,7 +4,8 @@
|
||||
"mochaExplorer.exit": true,
|
||||
"search.exclude": {
|
||||
"**/.docusaurus": true,
|
||||
"**/build": true
|
||||
"**/build": true,
|
||||
"**/schema": true
|
||||
},
|
||||
"tailwindCSS.files.exclude": [
|
||||
"docsite/**"
|
||||
|
||||
@@ -90,6 +90,8 @@ RUN npm run build:parallel && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
|
||||
FROM base AS app
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/FoxxMD/multi-scrobbler"
|
||||
|
||||
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
|
||||
@@ -106,6 +108,8 @@ ARG APP_BUILD_VERSION
|
||||
ENV APP_VERSION=$APP_BUILD_VERSION
|
||||
|
||||
RUN npm ci --omit=dev --no-audit \
|
||||
# we used compiled schema json for production so this isn't needed
|
||||
&& npm uninstall ts-json-schema-generator \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf /root/.cache
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<img src="/assets/icon.png" align="right"
|
||||
alt="multi-scrobbler logo" width="180" height="180">
|
||||
|
||||
A javascript app to scrobble music you listen to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), [ListenBrainz](https://listenbrainz.org), and other services.
|
||||
A dockerized app that monitors your music listening activity from *everywhere* and scrobbles it *anywhere*.
|
||||
|
||||
* Supports scrobbling from many [**Sources**](https://foxxmd.github.io/multi-scrobbler/configuration/sources)
|
||||
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/azuracast)
|
||||
@@ -22,6 +22,7 @@ A javascript app to scrobble music you listen to, to [Maloja](https://github.com
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/configuration/sources/koito-source)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-source)
|
||||
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-endpoint)
|
||||
* [Libre.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/librefm-source)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-endpoint)
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/configuration/sources/maloja)
|
||||
@@ -31,6 +32,7 @@ A javascript app to scrobble music you listen to, to [Maloja](https://github.com
|
||||
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/configuration/sources/musikcube)
|
||||
* [Plex](https://foxxmd.github.io/multi-scrobbler/configuration/sources/plex)
|
||||
* [Rocksky](https://foxxmd.github.io/multi-scrobbler/configuration/sources/rocksky-source)
|
||||
* [Sonos](https://foxxmd.github.io/multi-scrobbler/configuration/sources/sonos)
|
||||
* [Spotify](https://foxxmd.github.io/multi-scrobbler/configuration/sources/spotify)
|
||||
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/configuration/sources/subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [teal.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/tealfm-source)
|
||||
@@ -39,13 +41,15 @@ A javascript app to scrobble music you listen to, to [Maloja](https://github.com
|
||||
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/yamaha-musiccast)
|
||||
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/configuration/sources/youtube-music)
|
||||
* Supports scrobbling to many [**Clients**](https://foxxmd.github.io/multi-scrobbler/configuration/clients)
|
||||
* [Discord](https://foxxmd.github.io/multi-scrobbler/configuration/clients/discord) (Now Playing)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/configuration/clients/koito)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/lastfm)
|
||||
* [Libre.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/librefm)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/configuration/clients/listenbrainz)
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/configuration/clients/maloja)
|
||||
* [Rocksky](https://foxxmd.github.io/multi-scrobbler/configuration/clients/rocksky)
|
||||
* [teal.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/tealfm)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/configuration#health-endpoint)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/configuration#webhook-configurations), [healthcheck endpoints](https://foxxmd.github.io/multi-scrobbler/configuration#health-endpoint), or [prometheus metrics](https://foxxmd.github.io/multi-scrobbler/configuration/#prometheus).
|
||||
* Supports [Now Playing](https://foxxmd.github.io/multi-scrobbler/configuration/clients#now-playing) for scrobble Clients
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
@@ -57,7 +61,7 @@ A javascript app to scrobble music you listen to, to [Maloja](https://github.com
|
||||
|
||||
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler//quickstart)
|
||||
|
||||
<img src="/assets/status-ui.png" width="800">
|
||||
<img src="/docsite/static/img/status-ui.png" width="800">
|
||||
|
||||
**Why should I use this over a browser extension and/or mobile app scrobbler?**
|
||||
|
||||
@@ -76,7 +80,7 @@ You set up [configurations](https://foxxmd.github.io/multi-scrobbler/configurati
|
||||
|
||||
### Source
|
||||
|
||||
A [**Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources) is a data source that contains information about music you are playing or have listened to, like a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
|
||||
A [**Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources) is a data source that contains information about music you are playing or have listened to, such as: a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
|
||||
|
||||
Source configurations consist of:
|
||||
|
||||
@@ -86,7 +90,7 @@ Source configurations consist of:
|
||||
|
||||
### Client
|
||||
|
||||
A [**Client**](https://foxxmd.github.io/multi-scrobbler/configuration/clients) is an application that stores the historical information about what music you have played (scrobbles). Examples are **Maloja, Last.fm, Listenbrainz**...
|
||||
A [**Client**](https://foxxmd.github.io/multi-scrobbler/configuration/clients) is an application that stores the historical information about what music you have played (scrobbles). Examples are **Koito, Last.fm, Listenbrainz**...
|
||||
|
||||
Client configurations consist of:
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
FROM lsiobase/alpine:3.18 as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
RUN \
|
||||
echo "**** install build packages ****" && \
|
||||
apk add --no-cache \
|
||||
avahi \
|
||||
avahi-tools \
|
||||
nodejs \
|
||||
npm \
|
||||
&& \
|
||||
echo "**** cleanup ****" && \
|
||||
rm -rf \
|
||||
/root/.cache \
|
||||
/tmp/*
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
|
||||
COPY docker/root/ /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base as build
|
||||
|
||||
# copy dep/TS config and install dev dependencies
|
||||
COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
|
||||
RUN npm ci \
|
||||
&& chown -R root:root node_modules
|
||||
#RUN yarn install
|
||||
|
||||
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
|
||||
|
||||
FROM base as app
|
||||
|
||||
#COPY --chown=abc:abc package.json yarn.lock ./
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
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
|
||||
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
|
||||
COPY --from=base /usr/local/bin /usr/local/bin
|
||||
COPY --from=base /usr/local/lib /usr/local/lib
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
ENV COLORED_STD=true
|
||||
|
||||
# https://stackoverflow.com/a/63640896/1469797
|
||||
ARG APP_BUILD_VERSION
|
||||
ENV APP_VERSION=$APP_BUILD_VERSION
|
||||
|
||||
RUN npm ci --omit=dev \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
EXPOSE $PORT
|
||||
@@ -0,0 +1 @@
|
||||
* Attribution for [`default-artwork.png`](/assets/default-artwork.png): https://www.flaticon.com/free-icon/music_15795471
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 699 KiB |
+42
-29
@@ -1,18 +1,20 @@
|
||||
# configuration file for git-cliff (0.1.0)
|
||||
# git-cliff ~ configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
# A Tera template to be rendered as the changelog's header.
|
||||
# See https://keats.github.io/tera/docs/#introduction
|
||||
header = """
|
||||
# Changelog
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://tera.netlify.app/docs/#introduction
|
||||
# A Tera template to be rendered for each release in the changelog.
|
||||
# See https://keats.github.io/tera/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | replace(from="v", to="") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
## Unreleased
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
@@ -24,6 +26,7 @@ body = """
|
||||
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{% raw %}\n{% endraw %}\
|
||||
{%- for commit in commits %}
|
||||
{%- if commit.scope -%}
|
||||
{% else -%}
|
||||
@@ -33,35 +36,45 @@ body = """
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
{% endfor -%}
|
||||
{% endfor %}
|
||||
{% raw %}\n{% endraw %}\
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# remove the leading and trailing whitespaces from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
# A Tera template to be rendered as the changelog's footer.
|
||||
# See https://keats.github.io/tera/docs/#introduction
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
# Remove leading and trailing whitespaces from the changelog's body.
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# allow only conventional commits
|
||||
# https://www.conventionalcommits.org
|
||||
# Parse commits according to the conventional commits specification.
|
||||
# See https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# regex for parsing and grouping commits
|
||||
# Exclude commits that do not match the conventional commits specification.
|
||||
filter_unconventional = true
|
||||
# An array of regex based parsers for extracting data from the commit message.
|
||||
# Assigns commits to groups.
|
||||
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features"},
|
||||
{ message = "^fix", group = "Bug Fixes"},
|
||||
{ message = "^doc", group = "Documentation"},
|
||||
{ message = "^perf", group = "Performance"},
|
||||
{ message = "^refactor", group = "Refactor"},
|
||||
{ message = "^style", group = "Styling"},
|
||||
{ message = "^test", group = "Testing"},
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true},
|
||||
{ message = "^chore", group = "Miscellaneous Tasks"},
|
||||
{ body = ".*security", group = "Security"},
|
||||
{ message = "^feat", group = "⛰️ Features" },
|
||||
{ message = "^fix", group = "🐛 Bug Fixes" },
|
||||
{ message = "^doc", group = "📚 Documentation" },
|
||||
{ message = "^perf", group = "⚡ Performance" },
|
||||
{ message = "^refactor", group = "🚜 Refactor" },
|
||||
{ message = "^style", group = "💅 Styling" },
|
||||
{ message = "^test", group = "🧪 Testing" },
|
||||
{ message = "^ci", group = "🧑🍳 Continuous Integration" },
|
||||
{ message = "^build", group = "🐋 Build" },
|
||||
{ message = "^chore\\(release\\)", skip = true },
|
||||
{ message = "^chore", group = "⚙️ Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "🛡️ Security" },
|
||||
]
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
# Exclude commits that are not matched by any commit parser.
|
||||
filter_commits = false
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# Order releases topologically instead of chronologically.
|
||||
topo_order = false
|
||||
# Order of commits in each group/release within the changelog.
|
||||
# Allowed values: newest, oldest
|
||||
sort_commits = "oldest"
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MS",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"applicationId": "8190211179716453570",
|
||||
"ipcLocations": ["192.168.0.105:6655"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MS",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
|
||||
"applicationId": "8190211179716453570"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"librariesAllow": ["GoodMusic"],
|
||||
"librariesBlock": ["BadMusic"],
|
||||
"additionalAllowedLibraryTypes": ["musicvideos"],
|
||||
"allowUnknown": false,
|
||||
"allowMediaTypes": ["audio"],
|
||||
"frontendUrlOverride": "https://myJellyfinExternal.tld"
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"name": "myLibreFmClient",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"apiKey": "anything",
|
||||
"secret": "anything",
|
||||
"redirectUri": "http://localhost:9078/librefm/callback"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "myLibreFmSource",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"apiKey": "anything",
|
||||
"secret": "anything",
|
||||
"redirectUri": "http://localhost:9078/librefm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -16,7 +16,7 @@
|
||||
"options": {
|
||||
"logPayload": true,
|
||||
"logFilterFailure": "debug",
|
||||
"ignoreInvalidCert": "false"
|
||||
"ignoreInvalidCert": false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"enable": true,
|
||||
"name": "MySonos",
|
||||
"data": {
|
||||
"host": "192.168.0.150",
|
||||
"devicesAllow": ["move","beam"],
|
||||
"devicesBlock": ["roam"],
|
||||
"groupsAllow": ["family"],
|
||||
"groupsBlock": ["bedroom"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "MyTautuilli",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logFilterFailure": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -12,7 +12,11 @@
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"identifier": "alice.bsky.social",
|
||||
"appPassword": "twog-phu7-4dhe-y4j3"
|
||||
"appPassword": "twog-phu7-4dhe-y4j3",
|
||||
|
||||
// everything below is optional
|
||||
"serviceAllow": ["plyr"],
|
||||
"serviceDeny": ["last.fm"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
multi-scrobbler:
|
||||
volumes:
|
||||
- kasm_run:/run
|
||||
kasmcord:
|
||||
container_name: kasmcord
|
||||
image: kasmweb/discord:1.14.0
|
||||
ports:
|
||||
# VNC port to use in web browser
|
||||
- 6901:6901
|
||||
shm_size: 512m
|
||||
environment:
|
||||
# set a password for VNC access
|
||||
VNC_PW: ${KASM_PW:-hunter2}
|
||||
XDG_RUNTIME_DIR: /run/user/1000
|
||||
volumes:
|
||||
- kasm_run:/run/user/1000
|
||||
- kasm_config:/home/kasm-user/.config
|
||||
user: "0"
|
||||
entrypoint: sh -c "chmod 700 /run/user/1000 && chown -R kasm-user:kasm-user /run/user/1000 && chown -R kasm-user:kasm-user /home/kasm-user/.config && su kasm-user -c '/dockerstartup/kasm_default_profile.sh /dockerstartup/vnc_startup.sh /dockerstartup/kasm_startup.sh'"
|
||||
|
||||
volumes:
|
||||
kasm_run:
|
||||
name: kasm_run
|
||||
kasm_config:
|
||||
name: kasm_config
|
||||
+17
-2
@@ -60,6 +60,21 @@ If multi-scrobbler is not running on the same machine your browser is on then th
|
||||
|
||||
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
|
||||
|
||||
### I get errors with `ECONNRESET` `ETIMEDOUT` `ENOTFOUND` `EAI_AGAIN`
|
||||
|
||||
These are errors indicating a network issue is occuring between you and the Source/Client.
|
||||
|
||||
* `ECONNRESET` means the upstream service unexpectedly closed the connection
|
||||
* `ETIMEDOUT` means the upstream service did not respond to a request MS
|
||||
* `ENOTFOUND` means that MS could not find a DNS record for the domain of the upstream service
|
||||
* `EAI_AGAIN` means that a DNS server did not respond to MS's request to lookup the record for a domain
|
||||
|
||||
`ECONNRESET` and timeout issues can occur when you have a poor connection or if the upstream service is overloaded or is generally not behaving well. This is a common occurrence for Listenbrainz/Musicbrainz related requests.
|
||||
|
||||
Please check the [Upstream Services Status Page](https://status.multi-scrobbler.app) to independently verify if the service is misbehaving. If the service is up and has good uptime then the issue may be in your network.
|
||||
|
||||
`ENOTFOUND` and `EAI_AGAIN` and generally caused by DNS caching (stale records) or misbehaving DNS servers. Though this can happen at the upstream service's end, it is *usually* an issue in your network. If you run a Pihole, AdGaurd, or other DNS solution try flushing the cache for the mishaving domain and check that your DNS application is working correctly.
|
||||
|
||||
### Youtube Music fails after some time
|
||||
|
||||
If you see errors in multi-scrobbler for YTM that contain **401** or **403** like
|
||||
@@ -70,7 +85,7 @@ Error: Could not send the specified request to browse. Status code: 401
|
||||
|
||||
then YTM has invalidated your authentication.
|
||||
|
||||
First, ensure you are NOT using [YoutubeTV authentication.](/configuration/sources/youtube-music?ytmAuth=ytt#authentication) 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.**
|
||||
First, ensure you are NOT using [YoutubeTV authentication.](/configuration/sources/youtube-music?ytmAuth=ytt#ytm-auth) 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
|
||||
|
||||
@@ -80,7 +95,7 @@ Refresh your authentication by using the **(Re)authenticate** link from MS's web
|
||||
|
||||
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/sources/youtube-music?ytmAuth=cookie#authentication) and then restart MS to potentially resolve the problem.
|
||||
To re-authenticate MS [follow the YTM instructions to retrieve a new set of cookies for multi-scrobbler](/configuration/sources/youtube-music?ytmAuth=cookie#ytm-auth) and then restart MS to potentially resolve the problem.
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ A **Client** is an application that stores the historical information about what
|
||||
|
||||
| Name | Now Playing |
|
||||
| :-------------------------------------------------- | :---------- |
|
||||
| [Discord](/configuration/clients/discord) | ✅ |
|
||||
| [Last.fm](/configuration/clients/lastfm) | ✅ |
|
||||
| [Libre.fm](/configuration/clients/librefm) | ✅ |
|
||||
| [Listenbrainz](/configuration/clients/listenbrainz) | ✅ |
|
||||
| [Koito](/configuration/clients/koito) | ❌ |
|
||||
| [Maloja](/configuration/clients/maloja) | ❌ |
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"label": "Discord",
|
||||
"link": {
|
||||
"type": "doc",
|
||||
"id": "configuration/clients/discord/discord"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
---
|
||||
title: Discord (App)
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import TcpWarning from "@site/src/components/snippets/_discord-port-warn.mdx";
|
||||
import JsonConfig from '!!raw-loader!@site/../config/discord-local.json.example';
|
||||
|
||||
This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler.
|
||||
|
||||
<img src={require('/img/discord_presence_art.png').default} height="127"/>
|
||||
|
||||
This integration uses an **existing Discord application** to set your Rich Presence.
|
||||
|
||||
:::tip
|
||||
|
||||
Check the [main Discord docs page](/configuration/clients/discord) for a comparison between **App** and **Headless**.
|
||||
|
||||
:::
|
||||
|
||||
## Required Setup
|
||||
|
||||
### Discord Application
|
||||
|
||||
You must create a [Discord App](https://docs.discord.com/developers/quick-start/overview-of-apps) in order to use this integration. Creating an App is free and simple.
|
||||
|
||||
Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
|
||||
* Click on "New Application" and give it a name
|
||||
* Copy the **Application Id** (Client Id) shown after creation
|
||||
* Add this Id to your Multi-Scrobbler config
|
||||
* Env Config => `DISCORD_APPLICATION_ID=12345678`
|
||||
* File Config => `"applicationId": "12345678"`
|
||||
|
||||
### Discord Connection
|
||||
|
||||
This integration uses an existing, running Discord application to communicate with Discord and set Rich Presence. Multi-scrobbler must be able to communicate with the Discord application, either by being on the same machine or over a network.
|
||||
|
||||
<Tabs groupId="discordType" queryString>
|
||||
|
||||
<TabItem value="container" label="Containerized Discord">
|
||||
|
||||
[Kasm Workspaces](https://kasm.com) provides a self-hostable [Discord container image](https://hub.docker.com/r/kasmweb/discord). This image provides a desktop-like experience, through browser-based VNC, to use the official Discord application.
|
||||
|
||||
<img src={require('/img/discord_kasm.png').default} height="400"/>
|
||||
|
||||
This containerized approach can be used instead of configuring MS to use a [Native Discord](./?discordType=native#discord-connection) application on a specific machine.
|
||||
|
||||
**Advantages**
|
||||
|
||||
* Setup is much simpler
|
||||
* Discord runs in the same stack as multi-scrobbler
|
||||
* **Configuration is copy-paste.** Skip all of the [Discord Connection](#discord-connection) instructions.
|
||||
* Get a [Headless](/configuration/clients/discord/discord-headless)-like experience without needing to break Discord TOS
|
||||
|
||||
**Disadvantages**
|
||||
|
||||
* Image only available for x86 arch
|
||||
* Image and container are large (1GB+)
|
||||
* Discord does not notify *other* clients of notifications in a channel if *any* client is viewing that channel.
|
||||
* To avoid this, make sure your container discord instance is not viewing any server/channel/dm.
|
||||
* Sometimes, Discord does not deliver "notifications you missed" to mobile because this depends on *all* clients being offline.
|
||||
|
||||
<h2>Setup</h2>
|
||||
|
||||
Setup is basically the same as using the instructions for [Discord on Linux](./?osType=linux&discordType=native#find-ipc-connection) with [MS on Same Host (Docker)](./?osType=linux&discordType=native#pass-ipc-to-multi-scrobbler).
|
||||
|
||||
<h3>1. Add Discord Service to Multi-Scrobbler Docker Compose Stack</h3>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Detailed Explanation</summary>
|
||||
|
||||
* Add the `kasmweb/discord` service to your compose stack
|
||||
* Mount the directory containing Discord's unix socket to a named volume
|
||||
* Configure MS to use Discord's unix socket
|
||||
* Mount the unix socket volume into MS's container
|
||||
* Add ENV `DISCORD_IPC_LOCATIONS` to point to the mounted volume
|
||||
|
||||
</details>
|
||||
|
||||
Use this example `docker-compose.yaml` to modify your existing multi-scrobbler `docker-compose.yaml`:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler:latest
|
||||
environment:
|
||||
# ...
|
||||
- DISCORD_APPLICATION_ID=12345678
|
||||
# add below line
|
||||
// highlight-start
|
||||
- DISCORD_IPC_LOCATIONS=/run/discord-ipc-0
|
||||
// highlight-end
|
||||
volumes:
|
||||
# ...
|
||||
# add below line
|
||||
// highlight-start
|
||||
- kasm_run:/run
|
||||
// highlight-end
|
||||
# ...
|
||||
|
||||
# .. add discord service below
|
||||
// highlight-start
|
||||
kasmcord:
|
||||
container_name: kasmcord
|
||||
image: kasmweb/discord:1.14.0
|
||||
ports:
|
||||
# VNC port to use in web browser
|
||||
- 6901:6901
|
||||
shm_size: 512m
|
||||
environment:
|
||||
# set a password for VNC access
|
||||
VNC_PW: hunter2
|
||||
XDG_RUNTIME_DIR: /run/user/1000
|
||||
volumes:
|
||||
- kasm_run:/run/user/1000
|
||||
- kasm_config:/home/kasm-user/.config
|
||||
user: "0"
|
||||
entrypoint: sh -c "chmod 700 /run/user/1000 && chown -R kasm-user:kasm-user /run/user/1000 && chown -R kasm-user:kasm-user /home/kasm-user/.config && su kasm-user -c '/dockerstartup/kasm_default_profile.sh /dockerstartup/vnc_startup.sh /dockerstartup/kasm_startup.sh'"
|
||||
|
||||
volumes:
|
||||
kasm_run:
|
||||
name: kasm_run
|
||||
kasm_config:
|
||||
name: kasm_config
|
||||
// highlight-end
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Then re-deploy your stack.
|
||||
|
||||
<h3>2. Login to Discord</h3>
|
||||
|
||||
Navigate in your browser to the IP/host Multi-Scrobbler/Discord are running on. Use the `port` set in the `kasmcord` service:
|
||||
|
||||
```
|
||||
# basic auth login
|
||||
# user: kasm_user
|
||||
# pass is what was set in env VNC_PW
|
||||
https://192.168.MY.HOST:6901
|
||||
```
|
||||
|
||||
Login to Discord normally. After login is complete MS should connect automatically when a Now Playing event happens. If it does not or reports authentication failure then restart MS (but not Discord).
|
||||
|
||||
If MS works then you are done and can move on to [Optional Setup](#optional-setup).
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="native" label="Native Discord">
|
||||
|
||||
Use this method if you want to connect MS to Discord running on a physical desktop.
|
||||
|
||||
<DetailsAdmo type="tip" summary="Local MS Installation Requires No Config">
|
||||
|
||||
If MS was [installed locally](/installation#local-installation) (not Docker) on the same machine as the Discord application then MS should [automatically detect the correct method](#find-ipc-connection) and [use it](#pass-ipc-to-multi-scrobbler) **without** any configuration from you. This will work for **any OS**.
|
||||
|
||||
If MS cannot determine this automatically, follow the steps for your OS below.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
Follow the sections below to configure MS for your specific environment.
|
||||
|
||||
#### Find IPC Connection
|
||||
|
||||
Discord uses OS-specific methods to communicate with other applications on the same host, called [IPC](https://en.wikipedia.org/wiki/Inter-process_communication). You may need to determine some details about the IPC method in order for MS to communicate with it correctly.
|
||||
|
||||
|
||||
<Tabs groupId="osType" queryString>
|
||||
|
||||
<TabItem value="linux" label="Discord on Linux">
|
||||
|
||||
On Linux, Discord uses a [Unix Socket](https://en.wikipedia.org/wiki/Unix_domain_socket) for IPC.
|
||||
|
||||
With Discord running, run this command:
|
||||
|
||||
```
|
||||
$ sudo netstat -apeen | grep discord
|
||||
|
||||
unix 2 [ ACC ] STREAM LISTENING 11026149 1641787/Discord /home/foxx/.var/app/com.discordapp.Discord/cache/scoped_dirnnxNIn/SingletonSocket
|
||||
unix 2 [ ACC ] STREAM LISTENING 11036791 1642030/exe /run/user/1000/discord-ipc-0
|
||||
```
|
||||
|
||||
We see that the path we need is:
|
||||
|
||||
```
|
||||
/run/user/1000/discord-ipc-0
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="macos" label="Discord on MacOS">
|
||||
|
||||
On MacOS, Discord uses a [Unix Socket](https://en.wikipedia.org/wiki/Unix_domain_socket) for IPC.
|
||||
|
||||
With Discord running, run this command:
|
||||
|
||||
```
|
||||
$ sudo netstat -apeen | grep discord
|
||||
|
||||
unix 2 [ ACC ] STREAM LISTENING 11026149 1641787/Discord /home/foxx/.var/app/com.discordapp.Discord/cache/scoped_dirnnxNIn/SingletonSocket
|
||||
unix 2 [ ACC ] STREAM LISTENING 11036791 1642030/exe /run/user/1000/discord-ipc-0
|
||||
```
|
||||
|
||||
We see that the path we need is:
|
||||
|
||||
```
|
||||
/run/user/1000/discord-ipc-0
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="windows" label="Discord on Windows">
|
||||
|
||||
Windows uses [**Named Pipes**](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes) for IPC. These aren't easily iteratable without external software, like [Pipetap](https://github.com/sensepost/pipetap).
|
||||
|
||||
The syntax for a named pipe is like this:
|
||||
|
||||
```
|
||||
\\.\pipe\app_named_pipe_foo
|
||||
```
|
||||
|
||||
and Discord's names follow this pattern:
|
||||
|
||||
```
|
||||
\\.\pipe\discord-ipc-0
|
||||
```
|
||||
|
||||
Where `0` increments for each different instance of Discord (official, canary, nightly, etc...).
|
||||
|
||||
The default Discord pipe name is: `discord-ipc-0`
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
#### Pass IPC to Multi-Scrobbler
|
||||
|
||||
After [finding your IPC connection path](#find-ipc-connection) you will need to make it accessible to Multi-Scrobbler.
|
||||
|
||||
<Tabs groupId="osType" queryString>
|
||||
|
||||
<TabItem value="linux" label="Discord on Linux">
|
||||
|
||||
<Tabs groupId="msConnection">
|
||||
|
||||
<TabItem value="remote" label="MS on Different Host">
|
||||
|
||||
<TcpWarning/>
|
||||
|
||||
Use [`socat`](https://linux.die.net/man/1/socat) to bidirectionally relay communication from Discord's [unix socket](#find-ipc-connection) to a listening TCP port on the same host. Likely, you will want to setup a service to start this command on login.
|
||||
|
||||
```shell
|
||||
$ socat -v TCP-LISTEN:6655,reuseaddr,fork UNIX-CONNECT:/run/user/1000/discord-ipc-0
|
||||
```
|
||||
|
||||
In your MS docker compose file add the `host:port` of the host running Discord/`socat` to `DISCORD_IPC_LOCATIONS` `environment:`
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
|
||||
environment:
|
||||
# ...
|
||||
DISCORD_IPC_LOCATIONS=192.168.DISCORD_HOST.IP:6655
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="local-docker" label="MS on Same Host (Docker)">
|
||||
|
||||
In your MS docker compose file, pass the path of Discord's [unix socket](#find-ipc-connection) as a volume and add this path to `DISCORD_IPC_LOCATIONS` in `environment:`
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
|
||||
environment:
|
||||
# ...
|
||||
DISCORD_IPC_LOCATIONS=/run/discord-ipc-0
|
||||
volumes:
|
||||
- /run/user/1000/discord-ipc-0:/run/discord-ipc-0
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="local" label="MS on Same Host (Non-Docker)">
|
||||
|
||||
No configuration is required. If MS does not automatically work for you then try the **MS on Same Host (Docker)** method using the `DISCORD_IPC_LOCATIONS` environment variable.
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="macos" label="Discord on MacOS">
|
||||
|
||||
<Tabs groupId="msConnection">
|
||||
|
||||
<TabItem value="remote" label="MS on Different Host">
|
||||
|
||||
<TcpWarning/>
|
||||
|
||||
Use one of the methods below to setup `socat` on your macOS machine.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>`socat` installed natively</summary>
|
||||
|
||||
Install [`socat`](https://formulae.brew.sh/formula/socat) using homebrew to use it as a native command on your host.
|
||||
|
||||
Then, use [`socat`](https://linux.die.net/man/1/socat) to bidirectionally relay communication from Discord's [unix socket](#find-ipc-connection) to a listening TCP port on the same host. Likely, you will want to setup a service to start this command on login.
|
||||
|
||||
```shell
|
||||
$ socat -v TCP-LISTEN:6655,reuseaddr,fork UNIX-CONNECT:/run/user/1000/discord-ipc-0
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>`socat` in Docker</summary>
|
||||
|
||||
Use the [`socat` container](https://hub.docker.com/r/alpine/socat) in combination with the Discord [unix socket](#find-ipc-connection) found earlier:
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
services:
|
||||
socat:
|
||||
image: alpine/socat
|
||||
ports:
|
||||
- "6655:6655"
|
||||
volumes:
|
||||
- /run/user/1000/discord-ipc-0:/run/discord-ipc-0
|
||||
command:
|
||||
- '-v'
|
||||
- 'TCP-LISTEN:6655,reuseaddr,fork'
|
||||
- 'UNIX-CONNECT:/run/discord-ipc-0'
|
||||
```
|
||||
</details>
|
||||
|
||||
In your MS docker compose file add the `host:port` of the host running Discord/`socat` to `DISCORD_IPC_LOCATIONS` `environment:`
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
|
||||
environment:
|
||||
# ...
|
||||
DISCORD_IPC_LOCATIONS=192.168.DISCORD_HOST.IP:6655
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="local-docker" label="MS on Same Host (Docker)">
|
||||
|
||||
In your MS docker compose file, pass the path of Discord's [unix socket](#find-ipc-connection) as a volume and add this path to `DISCORD_IPC_LOCATIONS` in `environment:`
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
|
||||
environment:
|
||||
# ...
|
||||
DISCORD_IPC_LOCATIONS=/discord-ipc-0
|
||||
volumes:
|
||||
- /run/user/1000/discord-ipc-0:/run/discord-ipc-0
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="local" label="MS on Same Host (Non-Docker)">
|
||||
|
||||
No configuration is required. If MS does not automatically work for you then try the **MS on Same Host (Docker)** method using the `DISCORD_IPC_LOCATIONS` environment variable.
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="windows" label="Discord on Windows">
|
||||
|
||||
<Tabs groupId="msConnection">
|
||||
|
||||
<TabItem value="remote" label="MS in Docker">
|
||||
|
||||
:::note
|
||||
|
||||
While it is possible to use [named pipes directly in Docker](https://forums.docker.com/t/volume-mount-path-error/143626/3) this has not been tested and [may only work for windows containers](https://github.com/docker/for-win/issues/6813#issuecomment-632859857). If you are adventurous, try this out and if you can get it working please [report your findings in an issue.](https://github.com/FoxxMD/multi-scrobbler/issues)
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
||||
This method is **experimental** and may be buggy and prone to crashes. If you are a Windows or Rust developer please consider helping improve the [pipe proxy implementation](https://github.com/FoxxMD/named-pipe-to-tcp-proxy).
|
||||
|
||||
:::
|
||||
|
||||
<TcpWarning/>
|
||||
|
||||
Use [**pipeProxy**](https://github.com/FoxxMD/named-pipe-to-tcp-proxy) to connect to the Discord named pipe and expose it on a TCP port.
|
||||
|
||||
Download the latest [`pipeProxy.exe`](https://github.com/FoxxMD/named-pipe-to-tcp-proxy/releases) from the releases page.
|
||||
|
||||
The host/ip should be the IP of the machine running discord.
|
||||
|
||||
Run pipeProxy from the windows command line:
|
||||
|
||||
```
|
||||
pipeProxy.exe --pipe "\\.\pipe\discord-ipc-0" --tcp "192.168.0.100:6655"
|
||||
```
|
||||
|
||||
In your MS docker compose file add the `host:port` of the host running `pipeProxy` to `DISCORD_IPC_LOCATIONS` `environment:`
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
|
||||
environment:
|
||||
# ...
|
||||
DISCORD_IPC_LOCATIONS=192.168.0.100:6655
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="local" label="MS on Same Host (Non-Docker)">
|
||||
|
||||
No configuration is required. If MS does not automatically work for you then try the **MS in Docker** method using the `DISCORD_IPC_LOCATIONS` environment variable.
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Optional Setup
|
||||
|
||||
:::note
|
||||
|
||||
Due to the limitations of the Discord local API the other listening activity detection and online status customizations found in [Headless](/configuration/clients/discord/discord-headless#optional-setup) are not available for the Local integration.
|
||||
|
||||
:::
|
||||
|
||||
### Artwork
|
||||
|
||||
Multi-scrobbler can display Album Art in your Discord status. The image that is used differs based on if the image URL is publically accessible.
|
||||
|
||||
The order in which multi-scrobbler determines what image to use is the same as the order of the tabs shown below.
|
||||
|
||||
#### Artwork URL Types
|
||||
|
||||
<Tabs groupId="artworkUrl" queryString>
|
||||
<TabItem label="External URLs" value="external">
|
||||
MS can use external URLs for artwork.
|
||||
|
||||
<img src={require('/img/discord_presence_art.png').default} height="127"/>
|
||||
|
||||
External URLs that are not from known music services are **disabled by default.** This is to preserve *your* privacy because Discord does not host images, it only links to them and the URL is visible to other users.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Why Should I Care?</summary>
|
||||
|
||||
There are some scenarios where you may not want your artwork URLs to be visible, for example:
|
||||
|
||||
* A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`)
|
||||
* A Source may be internet-facing but you do not want to expose this information to discord/other users
|
||||
* A Source may be internet-facing but requires authentication to view the image
|
||||
</details>
|
||||
|
||||
MS will use your artwork URLs only if:
|
||||
|
||||
* artwork url is from a known music service/[Cover Art Archive](./?artworkUrl=caa#artwork-url-types) or
|
||||
* artwork url starts with `https` and
|
||||
* `artwork` (`DISCORD_ARTWORK`) is
|
||||
* `true` => always uses the url
|
||||
* a list of custom domains keywords an external artwork url should contain EX
|
||||
* ENV => `DISCORD_ARTWORK=mycdn,jellyfin`
|
||||
* File Config => `"artwork": ["mycdn","jellyfin"]`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
None of the below URLs will **ever** be used because they are not `https`
|
||||
|
||||
```
|
||||
http://foobar.com/cool.jpg <-- NOT allowed, not http
|
||||
http://192.168.0.112/fun.png <-- NOT allowed, not http
|
||||
```
|
||||
|
||||
When `DISCORD_ARTWORK` is not set:
|
||||
|
||||
```
|
||||
https://archive.org/download/dir/myimage.jpg <-- allowed, is https and known service
|
||||
https://spotify.com/assets/cover.png?size=1000 <-- allowed, is https and known service
|
||||
https://example.com/cool.jpg <-- NOT allowed, is https but not a known service and DISCORD_ARTWORK is not set
|
||||
https://anything.com/neat.jpg <-- NOT allowed, is https but not a known service and DISCORD_ARTWORK is not set
|
||||
```
|
||||
|
||||
Setting `DISCORD_ARTWORK=true`
|
||||
|
||||
```
|
||||
https://archive.org/my/dir/myimage.jpg <-- allowed, is https and known service
|
||||
http://foobar.com/cool.jpg <-- NOT allowed, not http
|
||||
https://example.com/cool.jpg <-- allowed, is https
|
||||
https://anything.com/neat.jpg <-- allowed, is https
|
||||
```
|
||||
|
||||
Setting `DISCORD_ARTWORK=mycdn,jellyfin`
|
||||
|
||||
```
|
||||
https://archive.org/my/dir/myimage.jpg <-- allowed, is https and known service
|
||||
http://foobar.com/cool.jpg <-- NOT allowed, not http
|
||||
https://example.com/cool.jpg <-- NOT allowed, is https but does not contain "mycdn" or "jellyfin"
|
||||
https://jellyfin.mydomain.com/neat.jpg <-- allowed, is https and contains "jellyfin"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Musicbrainz/Cover Art Achive" value="caa">
|
||||
MS can try to use [Cover Art Archive](http://coverartarchive.org/) to get album art. The URL it retrieves is public which means it acts like an External URL that is always available for you.
|
||||
|
||||
<img src={require('/img/discord_presence_art.png').default} height="127"/>
|
||||
|
||||
MS will try to use Cover Art Archive if:
|
||||
|
||||
* The scrobble data does not contain an album art url (no art in dashboard) or the existing url has failed an [External URL](./?artworkUrl=external#artwork-url-types) condition
|
||||
* and **the scrobble data contains a [Musicbrainz](/configuration/transforms/musicbrainz) Release (Album) MBID**
|
||||
|
||||
Basically, if MS would normally use the [MS Default](./?artworkUrl=ms-default#artwork-url-types) art, it will first try to use Cover Art Archive if the scrobble has a Release MBID.
|
||||
|
||||
Some Source, like [Jellyfin](/configuration/sources/jellyfin) and [Plex](/configuration/sources/plex), can automatically provide this ID if the music is "matched" in your library.
|
||||
|
||||
For all others, you can [**configure MS to use the Musicbrainz Stage**](/configuration/transforms/musicbrainz#use-metadata-for-discord-album-art) for Discord to try to get this MBID.
|
||||
</TabItem>
|
||||
<TabItem label="MS Default" value="ms-default">
|
||||
When these conditions are true:
|
||||
|
||||
* Cannot use an [External URL](./?artworkUrl=external#artwork-url-types) and
|
||||
* Cannot get album art from [Cover Art Archive](./?artworkUrl=caa#artwork-url-types) and
|
||||
* Play does not contain artwork information (no artwork shown in dashboard)
|
||||
|
||||
The status artwork will use a default URL instead of your artwork URL.
|
||||
|
||||
Without any other configuration, the default URL is an icon from the [multi-scrobbler repository](https://github.com/FoxxMD/multi-scrobbler/blob/master/assets/default-artwork.png):
|
||||
|
||||
<img src={require('/img/discord_presence_ms_default.png').default} height="127"/>
|
||||
|
||||
This default image can be customized:
|
||||
|
||||
* ENV Config => `DISCORD_ARTWORK_DEFAULT_URL=https://cooldomain.com/art.png`
|
||||
* File Config => `"artworkDefaultUrl": "https://cooldomain.com/art.png"`
|
||||
|
||||
Remember, this URL need to be accessible on the public internet.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_APPLICATION_ID` | Yes | | Application ID |
|
||||
| `DISCORD_IPC_LOCATIONS` | No | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
|
||||
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
</Config>
|
||||
@@ -0,0 +1,334 @@
|
||||
---
|
||||
title: Discord (Headless)
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import JsonConfig from '!!raw-loader!@site/../config/discord.json.example';
|
||||
|
||||
This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler.
|
||||
|
||||
<img src={require('/img/discord_presence_art.png').default} height="127"/>
|
||||
|
||||
This integration is **headless**, it does not depend on any specific Discord application to be running.
|
||||
|
||||
:::tip
|
||||
|
||||
Check the [main Discord docs page](/configuration/clients/discord) for a comparison between **App** and **Headless**.
|
||||
|
||||
:::
|
||||
|
||||
<DetailsAdmo type="note" summary="How MS Uses Discord API">
|
||||
|
||||
Multi-scrobbler does its upmost to implement, to-spec, [Gateway API communication](https://docs.discord.com/developers/events/gateway) and respect usage. It requires no [permissions](https://docs.discord.com/developers/events/gateway#gateway-intents) and reads no data other than monitoring what other user sessions are active.
|
||||
|
||||
Aside from the on-paper violation of using a user token programmatically (on your behalf), there is no misuse of the API to achieve rich presence.
|
||||
|
||||
Additionally, MS uses the state of non-MS sessions to conservatively update presence. [By default it will not conflict](#configure-when-presence-is-used) with any other official presence activities and is only used when you are actually active on a device.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
<DetailsAdmo type="note" summary="REQUIRED Interaction after Starting Multi-Scrobbler">
|
||||
|
||||
Every time multi-scrobbler is started a discord session update needs to be triggered so that [MS can capture the signals it uses to determine if presence can be updated.](#configure-when-presence-is-used) This only needs to be *once*, after starting multi-scrobbler. But it does need to be *every time* multi-scrobbler is (re)started. Without these signals MS will not update presence.
|
||||
|
||||
This session update can happen automatically after *some* time so you may not need to do anything. If you want to force the update then do one of the following:
|
||||
|
||||
* Open discord on a device it was not recently active on
|
||||
* Exit discord on an active device
|
||||
* Change your status (online, idle, etc...)
|
||||
* Set a custom status
|
||||
* Cause a presence update with a different app (listening to..., playing..., etc.)
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
## Required Setup
|
||||
|
||||
### User Token
|
||||
|
||||
You must provide a **Discord User Token** for your account in order to authenticate with Discord.
|
||||
|
||||
<DetailsAdmo type="warning" summary="User Token usage violates Discord TOS">
|
||||
|
||||
This functionality requires you to use your own **Discord User Token** in a way that is **against Discord's policies/terms of service.** If you use this scrobbler you do so **at your risk**.
|
||||
|
||||
There is no precedent for Discord punishing users who use their own token for non-abuse/spam reasons but there is also no guarantee this lack of enforcement will continue.
|
||||
|
||||
Refer to the **How MS Uses Discord API** section above to gauge whether this risk is acceptable to you.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
|
||||
<DetailsAdmo type="warning" summary="User Token is a Sensitive Credential">
|
||||
|
||||
Treat your User Token like a login credential. Anyone who has your token can access Discord as you.
|
||||
|
||||
When finding instructions for obtaining this token only run commands, or follow steps, that you can understand and trust. Do not run commands you do not understand. Never give your token to someone else.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
No instructions will be provided for obtaining a User Token because it is against Discord's policies to use it. Instead, you can search online for "how to obtain discord user token".
|
||||
|
||||
## Optional Setup
|
||||
|
||||
### Configure When Presence Is Used
|
||||
|
||||
MS uses several signals from your "real" Discord sessions to determine if it should update presence. The default configuration is conservative to ensure that MS does not conflict with other apps using presence. Additionally, it only updates if a "real" user device is active on discord.
|
||||
|
||||
:::tip[TLDR]
|
||||
|
||||
Without any additional configuration, MS will only update presence if:
|
||||
|
||||
* you have discord open on a real device
|
||||
* your status is either: online, idle, or dnd (not invisible)
|
||||
* no other apps are broadcasting listening activities (listening to spotify, etc...)
|
||||
|
||||
:::
|
||||
|
||||
#### Online Status
|
||||
|
||||
Configure if MS is allowed to update presence based on your online status. If this setting is not defined MS will only update if your status is **online**, **idle**, or **dnd**.
|
||||
|
||||
Allowed Values: `online` `idle` `dnd` `invisible`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Only allow presence updating when all of your "real" sessions are either online or idle.
|
||||
|
||||
|
||||
In File/AIO:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "MS",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
|
||||
"applicationId": "8190211179716453570",
|
||||
"statusOverrideAllow": ["online", "idle"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In ENV:
|
||||
|
||||
```
|
||||
DISCORD_STATUS_OVERRIDE_ALLOW=online,idle
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Other Listening Activities
|
||||
|
||||
Without any additional configuration, MS will not set Rich Presence if **other** apps are broadcasting **listening** activities EX "Listening to Spotify".
|
||||
|
||||
<details>
|
||||
|
||||
<summary>What about other activity types?</summary>
|
||||
|
||||
Discord uses an undocumented activity type priority to determine what activity is shown "first" for your profile. The Listening activity type has the *lowest priority*.
|
||||
|
||||
This means that if you start any other type of activity (streaming, watching, competing, etc...) it will take precedence over MS's listening activity.
|
||||
|
||||
Your listening activity will still be visible by clicking into your full profile.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Configure the names of **other** listening activities that MS **is allowed** to broadcast at the same time as.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
You want to allow MS to update presence even if Spotify is also broadcasting a listening activity.
|
||||
|
||||
In File/AIO:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "MS",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
|
||||
"applicationId": "8190211179716453570",
|
||||
"listeningActivityAllow": ["spotify"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In ENV:
|
||||
|
||||
```
|
||||
DISCORD_LISTENING_ACTIVITY_ALLOW=spotify
|
||||
```
|
||||
</details>
|
||||
|
||||
### Discord Application
|
||||
|
||||
Creating a [Discord App](https://docs.discord.com/developers/quick-start/overview-of-apps) enables you to use custom album cover art and secondary status images for more links. Creating an App is free and simple, it is recommended you do this to get the most functionality from Rich Presence.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Create An Application</summary>
|
||||
|
||||
Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
|
||||
* Click on "New Application" and give it a name
|
||||
* Copy the **Application Id** (Client Id) shown after creation
|
||||
* Add this Id to your Multi-Scrobbler config
|
||||
* Env Config => `DISCORD_APPLICATION_ID=12345678`
|
||||
* File Config => `"applicationId": "12345678"`
|
||||
|
||||
</details>
|
||||
|
||||
### Artwork
|
||||
|
||||
Multi-scrobbler can display Album Art in your Discord status. The image that is used differs based on if the image URL is publically accessible.
|
||||
|
||||
:::note
|
||||
|
||||
If no [**Application Id**](#discord-application) is configured the status art will **always** be the [Discord Default](./?artworkUrl=discord-default#artwork-url-types).
|
||||
|
||||
:::
|
||||
|
||||
The order in which multi-scrobbler determines what image to use is the same as the order of the tabs shown below.
|
||||
|
||||
#### Artwork URL Types
|
||||
|
||||
<Tabs groupId="artworkUrl" queryString>
|
||||
<TabItem label="External URLs" value="external">
|
||||
When [**Application Id**](#discord-application) **is** configured MS can use external URLs for artwork.
|
||||
|
||||
<img src={require('/img/discord_presence_art.png').default} height="127"/>
|
||||
|
||||
External URLs that are not from known music services are **disabled by default.** This is to preserve *your* privacy because Discord does not host images, it only links to them and the URL is visible to other users.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Why Should I Care?</summary>
|
||||
|
||||
There are some scenarios where you may not want your artwork URLs to be visible, for example:
|
||||
|
||||
* A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`)
|
||||
* A Source may be internet-facing but you do not want to expose this information to discord/other users
|
||||
* A Source may be internet-facing but requires authentication to view the image
|
||||
</details>
|
||||
|
||||
MS will use your artwork URLs only if:
|
||||
|
||||
* artwork url is from a known music service/[Cover Art Archive](./?artworkUrl=caa#artwork-url-types) or
|
||||
* artwork url starts with `https` and
|
||||
* `artwork` (`DISCORD_ARTWORK`) is
|
||||
* `true` => always uses the url
|
||||
* a list of custom domains keywords an external artwork url should contain EX
|
||||
* ENV => `DISCORD_ARTWORK=mycdn,jellyfin`
|
||||
* File Config => `"artwork": ["mycdn","jellyfin"]`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
None of the below URLs will **ever** be used because they are not `https`
|
||||
|
||||
```
|
||||
http://foobar.com/cool.jpg <-- NOT allowed, not http
|
||||
http://192.168.0.112/fun.png <-- NOT allowed, not http
|
||||
```
|
||||
|
||||
When `DISCORD_ARTWORK` is not set:
|
||||
|
||||
```
|
||||
https://archive.org/download/dir/myimage.jpg <-- allowed, is https and known service
|
||||
https://spotify.com/assets/cover.png?size=1000 <-- allowed, is https and known service
|
||||
https://example.com/cool.jpg <-- NOT allowed, is https but not a known service and DISCORD_ARTWORK is not set
|
||||
https://anything.com/neat.jpg <-- NOT allowed, is https but not a known service and DISCORD_ARTWORK is not set
|
||||
```
|
||||
|
||||
Setting `DISCORD_ARTWORK=true`
|
||||
|
||||
```
|
||||
https://archive.org/my/dir/myimage.jpg <-- allowed, is https and known service
|
||||
http://foobar.com/cool.jpg <-- NOT allowed, not http
|
||||
https://example.com/cool.jpg <-- allowed, is https
|
||||
https://anything.com/neat.jpg <-- allowed, is https
|
||||
```
|
||||
|
||||
Setting `DISCORD_ARTWORK=mycdn,jellyfin`
|
||||
|
||||
```
|
||||
https://archive.org/my/dir/myimage.jpg <-- allowed, is https and known service
|
||||
http://foobar.com/cool.jpg <-- NOT allowed, not http
|
||||
https://example.com/cool.jpg <-- NOT allowed, is https but does not contain "mycdn" or "jellyfin"
|
||||
https://jellyfin.mydomain.com/neat.jpg <-- allowed, is https and contains "jellyfin"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Musicbrainz/Cover Art Achive" value="caa">
|
||||
When [**Application Id**](#discord-application) **is** configured MS can try to use [Cover Art Archive](http://coverartarchive.org/) to get album art. The URL it retrieves is public which means it acts like an External URL that is always available for you.
|
||||
|
||||
<img src={require('/img/discord_presence_art.png').default} height="127"/>
|
||||
|
||||
MS will try to use Cover Art Archive if:
|
||||
|
||||
* The scrobble data does not contain an album art url (no art in dashboard) or the existing url has failed an [External URL](./?artworkUrl=external#artwork-url-types) condition
|
||||
* and **the scrobble data contains a [Musicbrainz](/configuration/transforms/musicbrainz) Release (Album) MBID**
|
||||
|
||||
Basically, if MS would normally use the [MS Default](./?artworkUrl=ms-default#artwork-url-types) art, it will first try to use Cover Art Archive if the scrobble has a Release MBID.
|
||||
|
||||
Some Source, like [Jellyfin](/configuration/sources/jellyfin) and [Plex](/configuration/sources/plex), can automatically provide this ID if the music is "matched" in your library.
|
||||
|
||||
For all others, you can [**configure MS to use the Musicbrainz Stage**](/configuration/transforms/musicbrainz#use-metadata-for-discord-album-art) for Discord to try to get this MBID.
|
||||
</TabItem>
|
||||
<TabItem label="MS Default" value="ms-default">
|
||||
When [**Application Id**](#discord-application) **is** configured and these conditions are true:
|
||||
|
||||
* Cannot use an [External URL](./?artworkUrl=external#artwork-url-types) and
|
||||
* Cannot get album art from [Cover Art Archive](./?artworkUrl=caa#artwork-url-types) and
|
||||
* Play does not contain artwork information (no artwork shown in dashboard)
|
||||
|
||||
The status artwork will use a default URL instead of your artwork URL.
|
||||
|
||||
Without any other configuration, the default URL is an icon from the [multi-scrobbler repository](https://github.com/FoxxMD/multi-scrobbler/blob/master/assets/default-artwork.png):
|
||||
|
||||
<img src={require('/img/discord_presence_ms_default.png').default} height="127"/>
|
||||
|
||||
This default image can be customized:
|
||||
|
||||
* ENV Config => `DISCORD_ARTWORK_DEFAULT_URL=https://cooldomain.com/art.png`
|
||||
* File Config => `"artworkDefaultUrl": "https://cooldomain.com/art.png"`
|
||||
|
||||
Remember, this URL need to be accessible on the public internet.
|
||||
</TabItem>
|
||||
<TabItem label="Discord Default" value="discord-default">
|
||||
When [**Application Id**](#discord-application) is **not** configured Discord will show its own image. No custom artwork can be used in this configuration.
|
||||
|
||||
This is also the image Discord will display if the artwork URL cannot be fetched.
|
||||
|
||||
<img src={require('/img/discord_presence_discord_default.png').default} height="127"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------------------- | --------- | ----------------- | :---------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions |
|
||||
| `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art |
|
||||
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | No | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
|
||||
</Config>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Discord
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler.
|
||||
|
||||
<img src={require('/img/discord_presence_art.png').default} height="127"/>
|
||||
|
||||
There are two types of Discord integrations to choose from:
|
||||
|
||||
## App
|
||||
|
||||
The **App** integration uses an **existing** Discord application on a desktop computer to set Rich Presence.
|
||||
|
||||
* Uses "permitted" access to Discord, no TOS issues
|
||||
* More restricted customization compared to Headless
|
||||
* Only active when your existing Discord application is open
|
||||
* For most scenarios it requires additional, OS-specific steps to setup correctly
|
||||
* May require an additional program on your machine to relay the Discord connection
|
||||
* This is potentially insecure, or less secure, then Headless
|
||||
* **Can use [discord in a container](/configuration/clients/discord/discord-app#discord-connection) as an easy alternative**
|
||||
* This is a Headless-like experience
|
||||
|
||||
[**App Docs**](/configuration/clients/discord/discord-app)
|
||||
|
||||
## Headless
|
||||
|
||||
The **Headless** integration uses Discord cloud Gateway/API, directly, to set Rich Presence.
|
||||
|
||||
* Does not require an existing Discord application (uses Discord cloud API)
|
||||
* Using this **Violates Discord TOS** because it requires a user token
|
||||
* More customization than App integration
|
||||
* Active if **any** Discord application is online
|
||||
|
||||
[**Headless Docs**](/configuration/clients/discord/discord-headless)
|
||||
@@ -54,7 +54,7 @@ Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito
|
||||
<Config config="KoitoClientConfig" fileContent={JsonConfig} client name="koito">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | ------- | --------------------------------- |
|
||||
| KOITO_TOKEN | Yes | | API Key from your Koito Account |
|
||||
| KOITO_USER | Yes | | Your Koito username |
|
||||
| KOITO_URL | No | | The base URL for the Koito server |
|
||||
| `KOITO_TOKEN` | Yes | | API Key from your Koito Account |
|
||||
| `KOITO_USER` | Yes | | Your Koito username |
|
||||
| `KOITO_URL` | No | | The base URL for the Koito server |
|
||||
</Config>
|
||||
@@ -11,24 +11,46 @@ import JsonConfig from '!!raw-loader!@site/../config/lastfm.json.example';
|
||||
|
||||
:::tip[Other Users]
|
||||
|
||||
* To monitor and re-scrobble activity from your Last.fm account create a [Last.fm (Source)](/configuration/sources/lastfm-source)
|
||||
* To monitor and re-scrobble activity from your Last.fm/Libre.fm account create a [Last.fm (Source)](/configuration/sources/lastfm-source)
|
||||
* To accept scrobbles from outside applications as if multi-scrobbler was Last.fm, create a [Last.fm (Endpoint)](/configuration/sources/lastfm-endpoint)
|
||||
|
||||
:::
|
||||
|
||||
[Register for an API account here.](https://www.last.fm/api/account/create)
|
||||
You will need a [Last.fm](https://www.last.fm) account to use the Last.fm API.
|
||||
|
||||
After creating your account, [register for an API account here.](https://www.last.fm/api/account/create)
|
||||
|
||||
The callback URL in the API account form is not used, but to keep it consistent use the same value you set using the [Callback/Redirect URL](#callbackredirect-url) section.
|
||||
|
||||
**Take note of your API Key and Secret after account creation.**
|
||||
|
||||
### Callback/Redirect URL
|
||||
|
||||
The Redirect (Callback) URL is used to redirect your **browser** to multi-scrobbler after authentication has completed.
|
||||
|
||||
So, the *base of the URL* you use should be the *same* as the URL you would use to navigate to the multi-scrobbler dashboard.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
If you can visit the multi-scrobbler dashboard by navigating to http://192.168.0.100:9078 in your web browser...
|
||||
|
||||
then the ENV `LASTFM_REDIRECT_URI`, or `redirectUri` for last.fm/libre.fm json config, should be
|
||||
|
||||
The Callback URL is actually specified by multi-scrobbler but to keep things consistent you should use
|
||||
```
|
||||
http://localhost:9078/lastfm/callback
|
||||
http://192.168.0.100:9078/lastfm/callback
|
||||
```
|
||||
or replace `localhost:9078` with your own base URL.
|
||||
|
||||
</details>
|
||||
|
||||
The Redirect URL can be explicitly specified in the configuration below, or by using the global [Base URL](/configuration#base-url) setting.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LastfmClientConfig" fileContent={JsonConfig} client name="lastfm">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|-----------------------------------------|-------------------------------------------------------------------------------|
|
||||
| ---------------------- | --------- | --------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `LASTFM_API_KEY` | Yes | | Api Key from your API Account |
|
||||
| `LASTFM_SECRET` | Yes | | Shared secret from your API Account |
|
||||
| `LASTFM_REDIRECT_URI` | No | `http://localhost:9078/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: Libre.fm
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import JsonConfig from '!!raw-loader!@site/../config/librefm.json.example';
|
||||
|
||||
:::tip[Other Users]
|
||||
|
||||
* To monitor and re-scrobble activity from your Libre.fm account create a [Libre.fm (Source)](/configuration/sources/librefm-source)
|
||||
|
||||
:::
|
||||
|
||||
To use the official [Libre.fm](https://libre.fm/) instance you will need an account. Or, you can run your own Libre.fm instance [using the legacy source code](https://github.com/foocorp/gnu-fm).
|
||||
|
||||
No API account, [API Key, or Secret](https://github.com/libre-fm/developer/wiki/Libre.fm-fundamentals#api-keys) is needed to use the Libre.fm API. Instead, use **any values** for API Key/Secret in the configuration. Authentication is done by allowing access from your account on redirect.
|
||||
|
||||
### Callback/Redirect URL
|
||||
|
||||
The Redirect (Callback) URL is used to redirect your **browser** to multi-scrobbler after authentication has completed.
|
||||
|
||||
So, the *base of the URL* you use should be the *same* as the URL you would use to navigate to the multi-scrobbler dashboard.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
If you can visit the multi-scrobbler dashboard by navigating to http://192.168.0.100:9078 in your web browser...
|
||||
|
||||
then the ENV `LIBREFM_REDIRECT_URI`, or `redirectUri` for libre.fm json config, should be
|
||||
|
||||
```
|
||||
http://192.168.0.100:9078/librefm/callback
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
The Redirect URL can be explicitly specified in the configuration below, or by using the global [Base URL](/configuration#base-url) setting.
|
||||
|
||||
### Custom Libre.fm instance
|
||||
|
||||
If you are **not** using the official Libre.fm instance then specify your custom instance using ENV `LIBREFM_URLBASE` or `urlBase` property in the `data` section in File/AIO config.
|
||||
|
||||
The value should be `protocol://libre.tld/path/prefix` EX:
|
||||
|
||||
```
|
||||
https://my.cool.libre.com/2.0/
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LibrefmClientConfig" fileContent={JsonConfig} client name="librefm">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ---------------------- | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `LIBREFM_ENABLE` | No | | Set to `true` to enable Libre.fm, if not using any of the below variables |
|
||||
| `LIBREFM_API_KEY` | No | `anything` | Optional API Key |
|
||||
| `LIBREFM_SECRET` | No | `anything` | Optional shared secret |
|
||||
| `LIBREFM_REDIRECT_URI` | No | `http://localhost:9078/librefm/callback` | Url to use for authentication. Must include `librefm/callback` somewhere in it |
|
||||
| `LIBREFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
|
||||
| `LIBREFM_URLBASE` | No | | Use for custom Libre.fm instance only. The host + path prefix EX `https://my.libre.example/2.0/` |
|
||||
</Config>
|
||||
@@ -22,10 +22,10 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenbrainzClientConfig" fileContent={JsonConfig} client name="listenbrainz">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|-------------------------------|---------------------------------|
|
||||
| LZ_TOKEN | Yes | | User token from your LZ profile |
|
||||
| LZ_USER | Yes | | Your LZ username |
|
||||
| LZ_URL | No | https://api.listenbrainz.org/ | The base URL for the LZ server |
|
||||
<Config config="ListenBrainzClientConfig" fileContent={JsonConfig} client name="listenbrainz">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ---------------------- | --------- | ----------------------------- | ------------------------------- |
|
||||
| `LZ_TOKEN` | Yes | | User token from your LZ profile |
|
||||
| `LZ_USER` | Yes | | Your LZ username |
|
||||
| `LZ_URL` | No | https://api.listenbrainz.org/ | The base URL for the LZ server |
|
||||
</Config>
|
||||
@@ -13,7 +13,7 @@ To monitor and re-scrobble activity **from** Rocksky create a [Rocksky (Source)]
|
||||
|
||||
:::
|
||||
|
||||
[**Rocksky**](https://docs.rocksky.app/) is music tracking and discovery platform built on the AT Protocol (Bluesky).
|
||||
[**Rocksky**](https://rocksky.app/) is music tracking and discovery platform built on the AT Protocol (Bluesky).
|
||||
|
||||
<details>
|
||||
|
||||
@@ -23,11 +23,21 @@ To monitor and re-scrobble activity **from** Rocksky create a [Rocksky (Source)]
|
||||
|
||||
</details>
|
||||
|
||||
:::warning
|
||||
<DetailsAdmo type="warning" summary="Your Data Is Public">
|
||||
|
||||
The default Bluesky PDS is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy.
|
||||
|
||||
:::
|
||||
</DetailsAdmo>
|
||||
|
||||
<DetailsAdmo type="warning" summary="No Scrobbling Guarantee">
|
||||
|
||||
Rocksky **does not guarantee** that every scrobble you send will be persisted. Rocksky's design goal is to store scrobbles with **metdata-complete, 3rd party verifiable track information** so that it can be a metadata-rich discovery platform, rather than an exhaustive scrobble data source-of-truth.
|
||||
|
||||
To do this it *asynchronously* matches your scrobble data against Musicbrainz's and/or Spotify's catalogs, *after the scrobble has been submitted.* **If the scrobble does not end up matching then it is not persisted.**
|
||||
|
||||
If you need your scrobbles to be 100% captured then you should scrobble to an *additional* [Scrobble Client](/configuration/clients).
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -54,9 +64,9 @@ The handle used with multi-scrobbler should be your **full** ATProto handle, inc
|
||||
|
||||
If you do not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
|
||||
|
||||
<Config config="RockskyClientConfig" fileContent={JsonConfig} client name="rocksky">
|
||||
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client name="rocksky">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | ------- | ----------------------------------------------------------------------------------- |
|
||||
| ROCKSKY_HANDLE | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` |
|
||||
| ROCKSKY_KEY | Yes | | API Key obtained from Rocksky developer dashboard |
|
||||
| `ROCKSKY_HANDLE` | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` |
|
||||
| `ROCKSKY_KEY` | Yes | | API Key obtained from Rocksky developer dashboard |
|
||||
</Config>
|
||||
@@ -26,22 +26,23 @@ To monitor and re-scrobble activity from a ATProto Profile create a [teal.fm (So
|
||||
|
||||
</details>
|
||||
|
||||
:::warning
|
||||
<DetailsAdmo type="warning" summary="Your Data Is Public">
|
||||
|
||||
The default Bluesky PDS is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy.
|
||||
|
||||
:::
|
||||
</DetailsAdmo>
|
||||
|
||||
:::note
|
||||
<DetailsAdmo type="note" summary="teal.fm Is Still In Development">
|
||||
|
||||
[teal.fm is still in developement](https://notes.teal.fm/3lzlwe6puis2l) and does not have a public website, yet.
|
||||
[teal.fm is still in developement](https://notes.teal.fm/3lzlwe6puis2l) and does not have a true production website, yet.
|
||||
|
||||
To view your teal.fm scrobbles you can:
|
||||
To view your teal.fm scrobble data you can:
|
||||
|
||||
* use [wrapped.baileytownsend.dev](https://wrapped.baileytownsend.dev/) which gives a simple stats overview of your scrobbles
|
||||
* use [atproto.at](https://atproto.at) to view the raw data (convenience URLs are generated in multi-scrobbler for you to do this)
|
||||
* use [teal-relay-production.up.railway.app](https://teal-relay-production.up.railway.app) or [teal-slice.wisp.place](https://tealfm-slice.wisp.place/) for a preview of teal.fm
|
||||
* use [yearinmusic.teal.fm](https://yearinmusic.teal.fm) or [wrapped.baileytownsend.dev](https://wrapped.baileytownsend.dev/) for wrapped-style stats of your teal.fm scrobbles
|
||||
* use [atproto.at](https://atproto.at) to view the raw data (convenience URLs are generated in multi-scrobbler, per scrobble, for you to do this)
|
||||
|
||||
:::
|
||||
</DetailsAdmo>
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -54,19 +55,24 @@ To view your teal.fm scrobbles you can:
|
||||
|
||||
## Configuration
|
||||
|
||||
### Handle
|
||||
### Identifier
|
||||
|
||||
The handle used with multi-scrobbler should be your **full** ATProto handle, including TLD.
|
||||
The ATPRoto identifier used with multi-scrobbler should be either:
|
||||
|
||||
* A valid [DID](https://atproto.com/specs/did#at-protocol-did-identifier-syntax), starting with `did:plc:...` or `did:web:...`
|
||||
* Your **full** ATProto handle, including TLD
|
||||
|
||||
If using a handle:
|
||||
|
||||
* For regular Bluesky account this will be like: `alice.bsky.social`
|
||||
* For Bluesky accounts that [use their website as their account](https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial) this is your domain: `mydomain.com`
|
||||
* For non-Bluesky users, you probably already know your handle
|
||||
* For Bluesky accounts that [use their domain as their account](https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial) this is your domain: `mydomain.com`
|
||||
* For non-Bluesky-PDS users, you probably already know your handle
|
||||
|
||||
If you do not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
|
||||
If your identifier does not look like a DID and does not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
|
||||
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} client name="tealfm">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | ------- | ----------------------------------------------------------------------------------- |
|
||||
| TEALFM_IDENTIFIER | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` |
|
||||
| TEALFM_APP_PW | Yes | | Bluesky/ATProto network App Password |
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | ------- | ------------------------------------------------------------------------------------------ |
|
||||
| `TEALFM_IDENTIFIER` | Yes | | Your **full** ATProto handle or DID. For Bluesky account this is like `myUser.bsky.social` |
|
||||
| `TEALFM_APP_PW` | Yes | | Bluesky/ATProto network App Password |
|
||||
</Config>
|
||||
@@ -96,7 +96,7 @@ Check the [**FAQ**](../FAQ.md) if you have any issues after configuration!
|
||||
:::tip
|
||||
* There are **example configurations** for all Source/Client types and AIO config located in the [`/config`](https://github.com/FoxxMD/multi-scrobbler/tree/master/config) directory of this project. These can be used as-is by renaming them to `.json`.
|
||||
* For docker installations these examples are copied to your configuration directory on first-time use.
|
||||
* There is also a [**kitchensink example**](/kitchensink) that provides examples of using all sources/clients in a complex configuration.
|
||||
* There is also a [**kitchensink example**](/configuration/kitchensink) that provides examples of using all sources/clients in a complex configuration.
|
||||
:::
|
||||
|
||||
Each file is named by the **type** of the Client/Source found in below sections. Each file as an **array** of that type of Client/Source.
|
||||
@@ -142,7 +142,7 @@ Check the [**FAQ**](../FAQ.md) if you have any issues after configuration!
|
||||
:::tip
|
||||
* An example AIO config files can be found in the project directory at [`/config/config.json.example`](https://github.com/FoxxMD/multi-scrobbler/tree/master/config/config.json.example)
|
||||
* For docker installations this example is copied to your configuration directory on first-time use.
|
||||
* There is also a [**kitchensink example**](/kitchensink) that provides examples of using all sources/clients in a complex configuration.
|
||||
* There is also a [**kitchensink example**](/configuration/kitchensink) that provides examples of using all sources/clients in a complex configuration.
|
||||
:::
|
||||
|
||||
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
|
||||
@@ -166,6 +166,84 @@ See the [Configuration Types](#configuration-types) above for your options for c
|
||||
|
||||
Each entry for a Source/Client includes a **Configuration** section that describes how to configure it using a [configuration type](#configuration-types).
|
||||
|
||||
## Secrets Interpolation
|
||||
|
||||
When using [File](./?configType=file#configuration-types) or [AIO](./?configType=aio#configuration-types) Configuration, Multi-Scrobbler can interpolate Environmental Variables into your json files. This can be used, for example, to keep sensitive data (like [Last.fm Client/Secret](/configuration/clients/lastfm#configuration)) out of your configuration files so that they can be committed to git.
|
||||
|
||||
Multi-scrobbler will look for patterns in text fields within _all_ of your json files:
|
||||
|
||||
* Some part of the text field matches: `[[MY_ENV]]`
|
||||
* Is replaced by the value of the Environmental Variable named `MY_ENV`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Given this [Last.fm](/configuration/clients/lastfm#configuration) [File](./?configType=file#configuration-types) config:
|
||||
|
||||
```json title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFmClient",
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"apiKey": "[[MY_APIKEY]]",
|
||||
"secret": "[[MY_SECRET]]",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
And these environmental variables, in this scenario set through `environment` in the [docker compose installation](/quickstart#create-docker-compose-file):
|
||||
|
||||
```
|
||||
MY_APIKEY=a89cba1569901a0671d5a9875fed4be1
|
||||
MY_SECRET=ec42e09d5ae0ee0f0816ca151008412a
|
||||
```
|
||||
|
||||
The resulting json multi-scrobbler would use:
|
||||
|
||||
```json title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFmClient",
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<DetailsAdmo type="note" summary="Caveats">
|
||||
|
||||
* ENV variable names/interpolation keys are case-insensitive
|
||||
* Interpolation only works for **string** values within json. This cannot be used for numbers, booleans, objects, etc...
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
<DetailsAdmo type="warning" summary="Missing ENVs">
|
||||
|
||||
Multi-scrobbler **will not** throw an error if the environmental value is not found. Instead, it will leave the string as-is and log a warning (`WARN` level) with the names of the missing environmental variable names like so:
|
||||
|
||||
```
|
||||
WARN : [App] [Sources] [spotify Secrets] Matched: None | Unmatched: SPOTIFY_SECRET
|
||||
```
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
<DetailsAdmo type="warning" summary="ENV Name Collisions">
|
||||
|
||||
**Verify that interpolation keys/environmental variable names you will use do not collide with existing ENV names used by multi-scrobbler.** Use the docs search to verify the name you want to use is not already used elsewhere by multi-scrobbler.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
## Application Options
|
||||
|
||||
These options affect multi-scrobbler's behavior and are not specific to any source/client.
|
||||
@@ -203,40 +281,163 @@ Useful when running with [docker](../installation/installation.mdx#docker) so th
|
||||
|
||||
### Caching
|
||||
|
||||
Multi-scrobbler can cache some of its data for the purpose of **persisting queued and dead scrobbles** after restarting.
|
||||
Multi-scrobbler caches some activities to persist important data across restarts, reduce external API calls, and make some actions faster.
|
||||
|
||||
It supports caching using either:
|
||||
All of the activities below are **always** cached **in-memory** with an optional, configurable [**secondary** store](#secondary-caching-configuration) for persistence.
|
||||
|
||||
* **File** cache stored in the `CONFIG_DIR` directory (next to your File/[AIO](./?configType=aio#configuration-types) file)
|
||||
* [**Valkey**](https://valkey.io/), an open-source fork of Redis.
|
||||
<Tabs groupId="cachedThings" queryString>
|
||||
<TabItem value="scrobbles" label="Scrobbles">
|
||||
|
||||
**File** caching is **enabled by default** when no other configuration is present.
|
||||
**Queued** and **Failed** Scrobbles are cached so that any un-scrobbled data you have is persisted across restarts of multi-scrobbler.
|
||||
|
||||
The type of cache used, and it's connection properties, can be configured through ENV or **AIO** config.
|
||||
:::tip
|
||||
|
||||
##### Caching Configuration
|
||||
By default, this data use a [Secondary](#secondary-caching-configuration) [File](./?cacheType=file#secondary-caching-configuration) store, configured for you automatically.
|
||||
|
||||
<Tabs groupId="cache" queryString>
|
||||
If you have configured a [persisted volume/bind mount](/installation#storage) for configuration (`/config` is mounted in [docker compose](/quickstart#create-docker-compose-file)) then you are already done. If you are not persisting this directory then you should consider setting up [Valkey Cache](./?cacheType=valkey#secondary-caching-configuration) for this.
|
||||
|
||||
:::
|
||||
|
||||
##### Configuration
|
||||
|
||||
Use any [Secondary Cache](#secondary-caching-configuration), the config examples below show the default values:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="env" label="ENV">
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | --------- | --------------------- |
|
||||
| `CACHE_SCROBBLE` | No | `file` | The cache type to use |
|
||||
| `CACHE_SCROBBLE_CONN` | No | `/config` | |
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"cache": {
|
||||
"scrobble": {
|
||||
"provider": "file",
|
||||
"connection": "/config"
|
||||
}
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="auth" label="Auth Data">
|
||||
|
||||
Authentication sessions/tokens/etc... are cached for quicker requests and for persistence across restarts.
|
||||
|
||||
:::tip
|
||||
|
||||
By default, this data use a [Secondary](#secondary-caching-configuration) [File](./?cacheType=file#secondary-caching-configuration) store, configured for you automatically.
|
||||
|
||||
If you have configured a [persisted volume/bind mount](/installation#storage) for configuration (`/config` is mounted in [docker compose](/quickstart#create-docker-compose-file)) then you are already done. If you are not persisting this directory then you should consider setting up [Valkey Cache](./?cacheType=valkey#secondary-caching-configuration) for this.
|
||||
|
||||
:::
|
||||
|
||||
##### Configuration
|
||||
|
||||
Use any [Secondary Cache](#secondary-caching-configuration), the config examples below show the default values:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="env" label="ENV">
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | --------- | --------------------- |
|
||||
| `CACHE_AUTH` | No | `file` | The cache type to use |
|
||||
| `CACHE_AUTH_CONN` | No | `/config` | |
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"cache": {
|
||||
"auth": {
|
||||
"provider": "file",
|
||||
"connection": "/config"
|
||||
}
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="metadata" label="Transform API Calls">
|
||||
|
||||
API Calls to external (metadata) services used to [Enhance Scrobbles](/configuration/transforms), like calls to [Musicbrainz](/configuration/transforms/musicbrainz), can be cached to avoid duplicate calls and speed up scrobble transformations.
|
||||
|
||||
By default, these calls are only cached in memory. If you wish for cached calls to be persisted across restarts then setup [Valkey Cache](./?cacheType=valkey#secondary-caching-configuration).
|
||||
|
||||
##### Configuration
|
||||
|
||||
Use any [Secondary Cache](#secondary-caching-configuration), the config examples below show the default values:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="env" label="ENV">
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | ------- | --------------------- |
|
||||
| `CACHE_METADATA` | No | | The cache type to use |
|
||||
| `CACHE_METADATA_CONN` | No | | |
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"cache": {
|
||||
"metadata": {
|
||||
"provider": "valkey",
|
||||
"connection": "yourConnectionStringHere"
|
||||
}
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
#### Secondary Caching Configuration
|
||||
|
||||
The type of cache used, and its connection properties, can be configured through **ENV** or **AIO** config.
|
||||
|
||||
<Tabs groupId="cacheType" queryString>
|
||||
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
**File** cache is stored in the `CONFIG_DIR` directory using the pre-defined file name `ms-scrobble.cache`.
|
||||
|
||||
It is **enabled by default** if `CACHE_SCROBBLE`, or the respective AIO configuration, is not defined.
|
||||
**File** cache is stored in the `CONFIG_DIR` directory using the pre-defined file name `ms-[cacheName].cache`.
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
|
||||
<TabItem value="env" label="ENV">
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | -------------------- | ------------------------------------------------------------ |
|
||||
| `CACHE_SCROBBLE` | No | `file` | The cache type to use |
|
||||
| `CACHE_SCROBBLE_CONN` | No | The config directory | The directory, within the container, to store the cache file |
|
||||
Example
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | --------- | ------------------------------------------------------------ |
|
||||
| `CACHE_SCROBBLE` | No | `file` | The cache type to use |
|
||||
| `CACHE_SCROBBLE_CONN` | No | `/config` | The directory, within the container, to store the cache file |
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
Example
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"cache": {
|
||||
@@ -256,23 +457,62 @@ It is **enabled by default** if `CACHE_SCROBBLE`, or the respective AIO configur
|
||||
|
||||
<TabItem value="valkey" label="Valkey">
|
||||
|
||||
[**Valkey**](https://valkey.io/) is an open-source fork of Redis.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Adding Valkey container to multi-scrobbler</summary>
|
||||
|
||||
A valkey container can be added to the [multi-scrobbler docker compose stack](/installation?runType=docker-compose#docker):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
# adding everything below
|
||||
// highlight-start
|
||||
valkey:
|
||||
image: valkey/valkey
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
|
||||
volumes:
|
||||
valkeydata:
|
||||
driver: local
|
||||
// highlight-end
|
||||
```
|
||||
|
||||
Use `redis://valkey:6379` as the connection string in the configurations below.
|
||||
|
||||
</details>
|
||||
|
||||
The connection string used by multi-scrobbler to connect to your Valkey instance must be in the form:
|
||||
|
||||
```
|
||||
redis://HOST_IP:HOST_PORT
|
||||
```
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
|
||||
<TabItem value="env" label="ENV">
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | -------------------- | ------------------------------------------------------------ |
|
||||
| `CACHE_SCROBBLE` | Yes | `valkey` | The cache type to use |
|
||||
| `CACHE_SCROBBLE_CONN` | Yes | | The host/IP and port to connect to, prefixed with `redis://` -- EX: `redis://192.168.0.120:6379` |
|
||||
Example
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | -------- | ------------------------------------------------------------------- |
|
||||
| `CACHE_METADATA` | Yes | `valkey` | The cache type to use |
|
||||
| `CACHE_METADATA_CONN` | Yes | | The host/IP and port to connect to EX: `redis://192.168.0.120:6379` |
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
Example
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"cache": {
|
||||
"scrobble": {
|
||||
"metadata": {
|
||||
"provider": "valkey",
|
||||
"connection": "redis://192.168.0.120:6379"
|
||||
}
|
||||
@@ -315,7 +555,7 @@ If you do not need the dashboard and/or ingress sources, or have security concer
|
||||
|
||||
:::warning
|
||||
|
||||
Any **[ingress-based sources](/configuration/sources#ingress) will be unusable** (Webscrobbler, etc...) if this is disabled.
|
||||
Any **[ingress-based sources](/configuration/sources?sourceComm=ingress#by-communication-method) will be unusable** (Webscrobbler, etc...) if this is disabled.
|
||||
|
||||
:::
|
||||
|
||||
@@ -326,7 +566,19 @@ Disable using either:
|
||||
|
||||
## Monitoring
|
||||
|
||||
Multi-scrobbler supports some common webhooks and a healthcheck endpoint in order to monitor Sources and Clients for errors.
|
||||
### Upstream Services Status
|
||||
|
||||
A status page for monitoring the reachability of public services used by some Sources/Clients is available at
|
||||
|
||||
**https://status.multi-scrobbler.app**
|
||||
|
||||
This monitor runs on a VPS and checks the uptime of *actual* APIs for these services, not just landing pages. On a ~minute interval it checks:
|
||||
|
||||
* Listenbrainz API
|
||||
* Musicbrainz API
|
||||
* CoverArtArchive API
|
||||
* Last.fm API
|
||||
* A few MS-hosted support services
|
||||
|
||||
### Webhook Configurations
|
||||
|
||||
@@ -454,11 +706,24 @@ multi-scrobbler supports [stateless](https://github.com/caronc/apprise-api?tab=r
|
||||
|
||||
### Health Endpoint
|
||||
|
||||
An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/health`
|
||||
An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/api/health`
|
||||
|
||||
* Returns `200 OK` when **everything** is working or `500 Internal Server Error` if **anything** is not
|
||||
* The plain url (`/health`) aggregates status of **all clients/sources** -- so any failing client/source will make status return 500
|
||||
* Use query params `type` or `name` to restrict client/sources aggregated IE `/health?type=spotify` or `/health?name=MyMaloja`
|
||||
* The plain url (`/api/health`) aggregates status of **all clients/sources** -- so any failing client/source will make status return 500
|
||||
* Use query params `type` or `name` to restrict client/sources aggregated IE `/api/health?type=spotify` or `/api/health?name=MyMaloja`
|
||||
* On 500 the response returns a JSON payload with `messages` array that describes any issues
|
||||
* For any clients/sources that require authentication `/health` will return 500 if they are **not authenticated**
|
||||
* For sources that poll (spotify, yt music, subsonic) `/health` will 500 if they are **not polling**
|
||||
* For any clients/sources that require authentication `/api/health` will return 500 if they are **not authenticated**
|
||||
* For sources that poll (spotify, yt music, subsonic) `/api/health` will 500 if they are **not polling**
|
||||
|
||||
### Prometheus
|
||||
|
||||
A [Prometheus](https://prometheus.io/) export endpoint is available at GET `http://YourMultiScrobblerDomain/api/metrics`
|
||||
|
||||
It includes metrics for:
|
||||
|
||||
* Count of discovered plays per [Source](/configuration/sources)
|
||||
* Count of Queued/Scrobbled/Deadletter scrobbles per [Client](/configuration/clients)
|
||||
* Number of issues per Source/Client
|
||||
* If any of these metrics is > 0 it means your Source/Client is not operating normally
|
||||
|
||||
Additionally, general process metrics like cpu and memory usage can be enabled with the env `PROMETHEUS_FULL=true`
|
||||
@@ -11,13 +11,13 @@ Scenario:
|
||||
* Each person has their own [Maloja](/configuration/clients/maloja) server
|
||||
* Each person has their own Spotify account
|
||||
* You have your own Airsonic ([subsonic](/configuration/sources/subsonic)) server you to scrobble from
|
||||
* You have your own [Youtube Music](/configuration/soures/youtube-mysic) account you want to scrobble from
|
||||
* You have your own [Youtube Music](/configuration/sources/youtube-music) account you want to scrobble from
|
||||
* Mary has her own [Last.fm](/configuration/clients/lastfm) account she also wants to scrobble to
|
||||
* Fred has his own [Spotify](/configuration/sources/spotify) application and provides you with just his access and refresh token because he doesn't trust you (wtf Fred)
|
||||
* Fred has a Plex server and wants to scrobble everything he plays
|
||||
* Mary uses Fred's Plex server but only wants to scrobble her plays from the `podcast` library
|
||||
* The three of you have a shared library on [Plex](/configuration/sources/plex) called `party` that you only play when you are hanging out. You want plays from that library to be scrobbled to everyone's servers.
|
||||
* Fred also has his own [Jellyfin server](/configurion/sources/jellyfin) and wants to scrobble everything he plays
|
||||
* Fred also has his own [Jellyfin server](/configuration/sources/jellyfin) and wants to scrobble everything he plays
|
||||
* You have an android music app that can scrobble to a [custom listenbrainz server](/configuration/sources/listenbrainz-endpoint)
|
||||
|
||||
### All-in-one Config
|
||||
|
||||
@@ -8,7 +8,7 @@ import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import AzuracastConfig from '!!raw-loader!@site/../config/azuracast.json.example';
|
||||
import ShouldScrobble from "@site/src/components/snippets/_should-scrobble.mdx"
|
||||
import ShouldScrobble from "@site/src/components/snippets/_should-scrobble-details.mdx"
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
@@ -36,14 +36,8 @@ URL From Config => MS Uses
|
||||
'wss://mydomain.com/custom/azura/ws' => wss://mydomain.com/custom/azura/ws
|
||||
```
|
||||
|
||||
#### Scrobble Behavior
|
||||
|
||||
:::info
|
||||
|
||||
<ShouldScrobble filename="azuracast"/>
|
||||
|
||||
:::
|
||||
|
||||
Multi-scrobbler will set the default **scrobbling behavior** based on if either of these settings is configured:
|
||||
|
||||
* live status (`AZURA_LIVE` or `monitorWhenLive`) or
|
||||
@@ -55,28 +49,11 @@ If neither is configured automatic scrobble defaults to **off** and you should e
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
<Config config="AzuracastSourceConfig" fileContent={AzuracastConfig} name="azuracast">
|
||||
| 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">
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/azuracast.json" language="json5">{AzuracastConfig}</CodeBlock>
|
||||
|
||||
|
||||
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
<AIOExample data={AzuracastConfig} name="azuracast"/>
|
||||
|
||||
|
||||
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</Config>
|
||||
@@ -44,28 +44,6 @@ import DeezerDeprecatedConfig from '!!raw-loader!@site/../config/deezer-deprecat
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | ------- | ------------------------------------------------ |
|
||||
| `DEEZER_ARL` | Yes | | The ARL cookie value retrieved from your browser |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/deezer.json" language="json5">{DeezerConfig}</CodeBlock>
|
||||
|
||||
or <SchemaLink lower objectName="DeezerSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
<AIOExample data={DeezerConfig} name="deezer"/>
|
||||
|
||||
or <SchemaLink lower objectName="DeezerSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Duplicate detection
|
||||
|
||||
Third party integrations with Deezer (Sonos) may cause Deezer to [report the same track many times in listening history.](https://github.com/FoxxMD/multi-scrobbler/pull/296#issuecomment-2922374738) If you experience this issue modify your Deezer Source config (file-based only) to include the option `"fuzzyDiscoveryIgnore": "aggressive"`
|
||||
@@ -91,6 +69,76 @@ import DeezerDeprecatedConfig from '!!raw-loader!@site/../config/deezer-deprecat
|
||||
|
||||
This option comes with some trade-offs: MS will aggressively detect repeated tracks within a window of time that should eliminate all duplicates. However, this will also prevent *intentionally* repeated tracks from being scrobbled. See [this thread](https://github.com/FoxxMD/multi-scrobbler/pull/296#issuecomment-2970417070) for more information on how this works.
|
||||
|
||||
### Family account
|
||||
|
||||
Multi-scrobbler can monitor listening history for accounts linked to a [Deezer Family Account](https://www.deezer.com/en/offers/family).
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Instructions</summary>
|
||||
|
||||
Start multi-scrobbler using the [ARL](#retrieve-arl) for the **main account**.
|
||||
|
||||
In the logs look for **Linked Accounts** associated with the Deezer Source. It will look something like this:
|
||||
|
||||
```
|
||||
VERBOSE: [App] [Sources] [Deezer - MyDeezer] Linked Accounts:
|
||||
Name: Joe | ID: 166276334 | Private?: Yes
|
||||
Name: Mary | ID: 48475231 | Private?: Yes
|
||||
Name: FoxxMD | ID: 896225281 | Private?: No
|
||||
Name: Cool Guy | ID: 128522478 | Private?: Yes
|
||||
```
|
||||
|
||||
The Account **ID** you want to monitor should be set:
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
Set in your docker compose `environment` section:
|
||||
|
||||
```yaml
|
||||
- DEEZER_ACCOUNT_ID=896225281
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File or AIO">
|
||||
Add an `accountId` property to the `data` section
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "DeezerARL",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"arl": "UOsRPjT3U5Dhaaup3xQ30D...",
|
||||
"accountId": "896225281"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Restart multi-scrobbler to start monitoring that account.
|
||||
|
||||
:::warning[Restrictions]
|
||||
|
||||
Deezer may not return the correct listening history under these circumstances:
|
||||
|
||||
* The ARL used is **not** for the main account
|
||||
* If you can get an ARL specifically for the linked account (login as the linked account), use that **instead** of family `accountId`
|
||||
* The ARL account is private
|
||||
* The linked account to be monitored ( using `accountId`) is private
|
||||
|
||||
:::
|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="DeezerCompatConfig" fileContent={DeezerConfig} name="deezer">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | ------- | ------------------------------------------------ |
|
||||
| `DEEZER_ARL` | Yes | | The ARL cookie value retrieved from your browser |
|
||||
</Config>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="official" label="Official API">
|
||||
|
||||
|
||||
@@ -144,8 +144,7 @@ To diagnose bad/incomplete track information or strange MS player behavior pleas
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
<Config config="ChromecastSourceConfig" fileContent={ChromecastConfig} name="chromecast">
|
||||
:::note
|
||||
[Manually configuring cast device connections](#connecting-devices) is only available through file-based config.
|
||||
:::
|
||||
@@ -157,28 +156,4 @@ To diagnose bad/incomplete track information or strange MS player behavior pleas
|
||||
| CC_BLACKLIST_DEVICES | No | | Do not scrobble from these Cast devices. Comma-delimited list |
|
||||
| CC_WHITELIST_APPS | No | | Only scrobble from these casted Apps. Comma-delimited list. EX spotify, pandora |
|
||||
| CC_BLACKLIST_APPS | No | | Do not scrobble from these casted Apps. Comma-delimited list |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/chromecast.json" language="json5">{ChromecastConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="ChromecastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={ChromecastConfig} name="chromecast"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="ChromecastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</Config>
|
||||
@@ -8,7 +8,7 @@ import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import IcecastConfig from '!!raw-loader!@site/../config/icecast.json.example';
|
||||
import ShouldScrobble from "@site/src/components/snippets/_should-scrobble.mdx"
|
||||
import ShouldScrobble from "@site/src/components/snippets/_should-scrobble-details.mdx"
|
||||
|
||||
Parse metadata from any Icecast Station (streams) to use for scrobbling.
|
||||
|
||||
@@ -20,13 +20,9 @@ Multi-scrobbler will do its best to parse Artist/Album/Track information from th
|
||||
|
||||
:::
|
||||
|
||||
:::note[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** to initiate scrobbling or configure automatic scrobble.
|
||||
|
||||
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](/configuration/sources#should-scrobble-behavior) to initiate scrobbling or configure automatic scrobble.
|
||||
|
||||
:::
|
||||
<ShouldScrobble filename="icecast"/>
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
@@ -66,32 +62,8 @@ If no `sources` are configured for multi-scrobbler it will automatically try to
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
<Config config="IcecastSourceConfig" fileContent={IcecastConfig} name="icecast">
|
||||
| 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>
|
||||
</Config>
|
||||
@@ -19,7 +19,7 @@ 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.**
|
||||
|
||||
:::tip[Important Defaults]
|
||||
<DetailsAdmo type="tip" summary="Important Defaults">
|
||||
|
||||
By default...
|
||||
|
||||
@@ -27,41 +27,43 @@ By default...
|
||||
* 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.
|
||||
* OR use `additionalAllowedLibraryTypes` to allow more *library* types (like those labelled as `mixed` or `book` for audiobooks)
|
||||
* multi-scrobbler will **only** scrobble media Jellyfin detects as **Audio**.
|
||||
* To allow other media types to be scrobbled set `allowMediaTypes` or `JELLYFIN_MEDIATYPES_ALLOW` with a list of [official types](https://github.com/jellyfin/jellyfin-sdk-typescript/blob/master/src/generated-client/models/media-type.ts#L22).
|
||||
* One or more of: `Unknown`, `Video`, `Audio`, `Photo`, `Book`
|
||||
* If you use this setting **you must explicitly specify `Audio`**, it will no longer be provided as a default
|
||||
|
||||
:::
|
||||
</DetailsAdmo>
|
||||
|
||||
<DetailsAdmo type="tip" summary="External Asset URL">
|
||||
|
||||
If you use MS and Jellyfin on an internal or otherwise inaccessible network but still want album art assets used in the UI or for [Discord](/config/clients/discord) to be accessible you can specify a URL to replace your internal url.
|
||||
|
||||
Use (file) `frontendUrlOverride` or (env) `JELLYFIN_FRONTEND_URL_OVERRIDE` to replace `url`/`JELLYFIN_URL`, respectively, when rendering links for the dashboard UI or other resources.
|
||||
|
||||
Example:
|
||||
|
||||
Your `JELLYFIN_URL` is `http://jellyfin:8096` but your Jellyfin instance is externally accessible (by MS or in general) at `https://jellyfin.mydomain.com`.
|
||||
|
||||
Use `JELLYFIN_FRONTEND_URL_OVERRIDE=https://jellyfin.mydomain.com` in order for the MS dashboard to use `https://jellyfin.mydomain.com/art/1234.jpg` when linking to album art.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
#### 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">
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/jellyfin.json" language="json5">{JellyfinConfig}</CodeBlock>
|
||||
|
||||
or <SchemaLink lower objectName="JellyApiSourceConfig"/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
<AIOExample data={JellyfinConfig} name="jellyfin"/>
|
||||
|
||||
|
||||
or <SchemaLink lower objectName="JellyApiSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
<Config config="JellyApiSourceConfig" fileContent={JellyfinConfig} name="jellyfin">
|
||||
| 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 |
|
||||
| `JELLYFIN_MEDIATYPES_ALLOW` | No | | Comma-separated list of [media types](https://github.com/jellyfin/jellyfin-sdk-typescript/blob/master/src/generated-client/models/media-type.ts#L22) to allow scrobbling. Defaults to `audio` |
|
||||
| `JELLYFIN_FRONTEND_URL_OVERRIDE` | No | | The external URL to use for album art assets instead of `JELLYFIN_URL` |
|
||||
</Config>
|
||||
@@ -49,28 +49,10 @@ If any part of this URL is missing multi-scrobbler will use a default value. Thi
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
<EnvType>
|
||||
<Config config="JRiverSourceConfig" fileContent={JriverConfig} name="jriver">
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|---------------------------------|------------------------------------------------|
|
||||
| JRIVER_URL | Yes | http://localhost:52199/MCWS/v1/ | The URL of the JRiver server |
|
||||
| JRIVER_USERNAME | No | | If authentication is enabled, the username set |
|
||||
| JRIVER_PASSWORD | No | | If authenticated is enabled, the password set |
|
||||
</EnvType>
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<FileType>
|
||||
<CodeBlock title="CONFIG_DIR/jriver.json" language="json5">{JriverConfig}</CodeBlock>
|
||||
|
||||
or <SchemaLink lower objectName="JRiverSourceConfig"/>
|
||||
</FileType>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<AIOType>
|
||||
<AIOExample data={JriverConfig} name="jriver"/>
|
||||
|
||||
or <SchemaLink lower objectName="JRiverSourceConfig"/>
|
||||
</AIOType>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</Config>
|
||||
@@ -19,13 +19,14 @@ To _scrobble to_ a Koito server, create a [Koito (Client)](/configuration/clien
|
||||
|
||||
See the [Koito (Client)](/configuration/clients/koito) configuration for general setup. The only difference for **Source** configuration:
|
||||
|
||||
* Cannot be setup with ENV config
|
||||
* [File/AIO config](/configuration?configType=file#configuration-types) must include `"configureAs": "source"`
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Config config="KoitoSourceConfig" fileContent={KoitoConfig} name="koito">
|
||||
:::note
|
||||
You cannot use ENV variables shown in the [Koito Client config](/configuration/clients/koito) -- multi-scrobbler assumes Koito ENVs are always used for the **client** configuration. You must use the [File or AIO](/configuration?configType=file#configuration-types) config to setup Koito as a Source.
|
||||
:::
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | --------- | ------- | --------------------------------- |
|
||||
| `SOURCE_KOITO_TOKEN` | Yes | | API Key from your Koito Account |
|
||||
| `SOURCE_KOITO_USER` | Yes | | Your Koito username |
|
||||
| `SOURCE_KOITO_URL` | No | | The base URL for the Koito server |
|
||||
</Config>
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Lastfm
|
||||
title: Last.fm
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
@@ -13,7 +13,7 @@ This Source **monitors your Last.fm scrobble history** and re-scrobbles new acti
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
* To **scrobble to** Last.fm, create a [Last.fm (Client)](/configuration/clients/lastfm)
|
||||
* To **scrobble to** Last.fm/Libre.fm, create a [Last.fm (Client)](/configuration/clients/lastfm)
|
||||
* To accept scrobbles from outside applications as if multi-scrobbler was Last.fm, create a [Last.fm (Endpoint)](/configuration/sources/lastfm-endpoint)
|
||||
|
||||
:::
|
||||
@@ -23,7 +23,10 @@ To configure this Source use the same registration instructions and configuratio
|
||||
## Configuration
|
||||
|
||||
<Config config="LastfmSourceConfig" fileContent={LastfmConfig} name="lastfm">
|
||||
:::note
|
||||
You cannot use ENV variables shown in the [Last.fm Client config](/configuration/clients/lastfm) -- multi-scrobbler assumes Last.fm ENVs are always used for the **client** configuration. You must use the [File or AIO](/configuration?configType=file#configuration-types) config to setup Last.fm as a Source.
|
||||
:::
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ---------------------------- | --------- | --------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `SOURCE_LASTFM_API_KEY` | Yes | | Api Key from your API Account |
|
||||
| `SOURCE_LASTFM_SECRET` | Yes | | Shared secret from your API Account |
|
||||
| `SOURCE_LASTFM_REDIRECT_URI` | No | `http://localhost:9078/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it |
|
||||
| `SOURCE_LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
|
||||
</Config>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Libre.fm
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import JsonConfig from '!!raw-loader!@site/../config/librefm.json.example';
|
||||
|
||||
This Source **monitors your Libre.fm scrobble history** and re-scrobbles new activity to configured [Clients.](/configuration/clients)
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
* To **scrobble to** Libre.fm, create a [Libre.fm (Client)](/configuration/clients/librefm)
|
||||
|
||||
:::
|
||||
|
||||
To configure this Source use the same registration instructions and configuration data shown in [Libre.fm (Client)](/configuration/clients/librefm) with the difference being `"configureAs": "source"` defined in the File/AIO configuration below.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LibrefmSourceConfig" fileContent={JsonConfig} name="librefm">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ----------------------------- | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `SOURCE_LIBREFM_ENABLE` | No | | Set to `true` to enable Libre.fm, if not using any of the below variables |
|
||||
| `SOURCE_LIBREFM_API_KEY` | No | `anything` | Optional API Key |
|
||||
| `SOURCE_LIBREFM_SECRET` | No | `anything` | Optional shared secret |
|
||||
| `SOURCE_LIBREFM_REDIRECT_URI` | No | `http://localhost:9078/librefm/callback` | Url to use for authentication. Must include `librefm/callback` somewhere in it |
|
||||
| `SOURCE_LIBREFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
|
||||
| `SOURCE_LIBREFM_URLBASE` | No | | Use for custom Libre.fm instance only. The host + path prefix EX `https://my.libre.example/2.0/` |
|
||||
</Config>
|
||||
@@ -25,7 +25,9 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenBrainzSourceConfig" fileContent={ListenbrainzConfig} name="listenbrainz">
|
||||
:::note
|
||||
You cannot use ENV variables shown in the [Listenbrainz Client config](/configuration/clients/listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the [File or AIO](/configuration?configType=file#configuration-types) config to setup Listenbrainz as a Source.
|
||||
:::
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ---------------------- | --------- | ----------------------------- | ------------------------------- |
|
||||
| `SOURCE_LZ_TOKEN` | Yes | | User token from your LZ profile |
|
||||
| `SOURCE_LZ_USER` | Yes | | Your LZ username |
|
||||
| `SOURCE_LZ_URL` | No | https://api.listenbrainz.org/ | The base URL for the LZ server |
|
||||
</Config>
|
||||
@@ -17,13 +17,13 @@ To _scrobble to_ a Maloja server, create a [Maloja (Client)](/configuration/cli
|
||||
|
||||
See the [Maloja (Client)](/configuration/clients/maloja) configuration for general setup. The only difference for **Source** configuration:
|
||||
|
||||
* Cannot be setup with ENV config
|
||||
* [File/AIO config](/configuration?configType=file#configuration-types) must include `"configureAs": "source"`
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MalojaSourceConfig" fileContent={MalojaConfig} name="maloja">
|
||||
:::note
|
||||
You cannot use ENV variables shown in the [Maloja Client config](/configuration/clients/maloja) -- multi-scrobbler assumes Maloja ENVs are always used for the **client** configuration. You must use the [[File or AIO](/configuration?configType=file#configuration-types) config to setup Maloja as a Source.
|
||||
:::
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ----------------------- | --------- | ------- | ----------------------------- |
|
||||
| `SOURCE_MALOJA_URL` | Yes | | Base URL of your installation |
|
||||
| `SOURCE_MALOJA_API_KEY` | Yes | | Api Key |
|
||||
</Config>
|
||||
@@ -63,6 +63,134 @@ By default multi-scrobbler will only scrobble media found in Plex libraries that
|
||||
|
||||
</details>
|
||||
|
||||
<DetailsAdmo type="tip" summary="Use Plex metadata with Musicbrainz for better scrobbling">
|
||||
|
||||
<h3>Matching With Plex</h3>
|
||||
|
||||
In Plex, if the **Plex Music** Agent is set on a Music Library then Plex attempts to match your music with Musicbrainz when it is first imported.
|
||||
|
||||
<img src={require('/img/plex_music_agent.jpg').default} height="400"/>
|
||||
|
||||
Plex uses ID3/metdata tags to match or falls back to matching based on file name and folder names. If it cannot find a match it can be fixed through a **Fix Match** option in the album/track context menu. See [plex documentation for more information](https://support.plex.tv/articles/correcting-your-music-content-matches/).
|
||||
|
||||
You can verify if an Item has been matched by:
|
||||
|
||||
* Clicking on the Item context menu (...)
|
||||
* Get Info
|
||||
* View XML
|
||||
|
||||
If a node like this is present then it has been matched: `<Guid id="mbid://4e631558-13b0..."/>`
|
||||
|
||||
<h3>Using Musicbrainz With Multi-Scrobbler</h3>
|
||||
|
||||
The MBID from the above `<Guid>` node is a [Track](https://musicbrainz.org/doc/Track), a special type of ID that specifically identifies a song on a specific album. If this is present then Multi-scrobbler can use it to gaurantee your scrobble, and all its metadata, is matched 1:1 with what is in your Plex library when using the [Musicbrainz Stage](/configuration/transforms/musicbrainz) to enrich your scrobble.
|
||||
|
||||
To configure MS to use Musicbrainz with your Plex metadata use one of the methods below:
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
Add to your multi-scrobbler docker compose file `environment` section for [Musicbrainz ENV Configuration](/configuration/transforms/musicbrainz#env-configuration):
|
||||
```yaml
|
||||
# a real email that Musicbrainz can use to contact you in case of issues
|
||||
- MB_CONTACT=myEmail@myEmailProvider.com
|
||||
- MB_PRESETS=id,default
|
||||
- PLEX_TRANSFORMS=musicbrainz
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="file" label="File">
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"data": {
|
||||
"apis": [
|
||||
{
|
||||
// a real email that Musicbrainz can use to contact you in case of issues
|
||||
"contact": "myEmail@myEmailProvider.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In your [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="plex.json"
|
||||
[
|
||||
{
|
||||
"name": "MyPlex",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB"
|
||||
"searchOrder": ["mbidrecording", "basicOrIds", "basic"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="File AIO">
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"data": {
|
||||
"apis": [
|
||||
{
|
||||
// a real email that Musicbrainz can use to contact you in case of issues
|
||||
"contact": "myEmail@myEmailProvider.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
// ...
|
||||
"sources": [
|
||||
// ...
|
||||
{
|
||||
"name": "MyPlex",
|
||||
"type": "plex",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"searchOrder": ["mbidrecording", "basicOrIds", "basic"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</DetailsAdmo>
|
||||
|
||||
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Config config="PlexApiSourceConfig" fileContent={PlexConfig} name="plex">
|
||||
|
||||
@@ -15,7 +15,7 @@ import JsonConfig from '!!raw-loader!@site/../config/rocksky.json.example';
|
||||
|
||||
:::
|
||||
|
||||
[**Rocksky**](https://docs.rocksky.app/) is music tracking and discovery platform built on the AT Protocol (Bluesky).
|
||||
[**Rocksky**](https://rocksky.app/) is music tracking and discovery platform built on the AT Protocol (Bluesky).
|
||||
|
||||
This Source monitors the Rocksky scrobbles in your PDS and then re-scrobbles discovered tracks to configured clients.
|
||||
|
||||
@@ -29,11 +29,11 @@ Follow the **Setup** instructions in the [Rocksky (Client)](/configuration/clien
|
||||
|
||||
See the [Rocksky (Client)](/configuration/clients/rocksky#setup) configuration for general setup. The only difference for **Source** configuration:
|
||||
|
||||
* Cannot be setup with ENV config
|
||||
* [File/AIO config](/configuration?configType=file#configuration-types) must include `"configureAs": "source"`
|
||||
|
||||
<Config config="RockskyClientConfig" fileContent={JsonConfig} name="rocksky">
|
||||
:::note
|
||||
You cannot use ENV variables shown in the [Rocksky Client config](/configuration/clients/rocksky#configuration) -- multi-scrobbler assumes Rocksky ENVs are always used for the **client** configuration. You must use the [File or AIO](/configuration?configType=file#configuration-types) config to setup Rocksky as a Source.
|
||||
:::
|
||||
<Config config="RockskySourceConfig" fileContent={JsonConfig} name="rocksky">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :---------------------- | --------- | ------- | ----------------------------------------------------------------------------------- |
|
||||
| `SOURCE_ROCKSKY_HANDLE` | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` |
|
||||
| `SOURCE_ROCKSKY_KEY` | Yes | | API Key obtained from Rocksky developer dashboard |
|
||||
</Config>
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Sonos
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import JsonConfig from '!!raw-loader!@site/../config/sonos.json.example';
|
||||
|
||||
Multi-scrobbler can use the [Sonos](https://www.sonos.com) [local API](https://docs.sonos.com/docs/control) to monitor what your Sonos system is playing.
|
||||
|
||||
MS supports scrobbling simultaneous, different Plays based on what each Group/Zone is playing.
|
||||
|
||||
<DetailsAdmo type="tip" summary="Use Musicbrainz for better scrobbling">
|
||||
|
||||
For some services, including Spotify, Sonos only reports the **first** artist for a Track. You will most likely want to enable [Musicbrainz scrobble enhancements](/configuration/transforms/musicbrainz) so that your plays have correct information, including all artists, when scrobbled.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
<DetailsAdmo type="note" summary="Avoid Sonos activity through other Sources">
|
||||
|
||||
Activity reporting for **other [Sources](/configuration/sources)** does not work well **when played through Sonos.**
|
||||
|
||||
If you have other Sources configured for Multi-Scrobbler that you may play *through Sonos* you should try to add Sonos to the "deny" list for that Source so that it does not try to monitor Sonos activity. Check the documentation for each individual Source to see how to configure this exclusion.
|
||||
|
||||
For example, to prevent the [Plex](/configuration/sources/plex) Source from monitoring audio played through Sonos, add Sonos to the "block" list ENV EX: `PLEX_DEVICES_BLOCK=sonos`
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>How to report issues</summary>
|
||||
|
||||
If your Sonos Source is logging errors or the scrobble data is not as expected please [create a bug report](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=01-bug-report.yml) **after** enabling the following debug logging options based on the config type you have:
|
||||
|
||||
<Tabs groupdId="sonosBugConfig">
|
||||
<TabItem value="env" label="ENV">
|
||||
|
||||
Enable [Debug Mode](/configuration#debug-mode) using `DEBUG_MODE=true`
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
Enable `logPayload` and `logFitlerFailure` in your `sonos.json`, or AIO file, config:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"enable": true,
|
||||
"name": "MySonos",
|
||||
"data": {
|
||||
"host": "192.168.0.150"
|
||||
},
|
||||
"options": {
|
||||
"logFilterFailure": "warn",
|
||||
"logPayload": true
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
Additional logging for the Sonos library may be enabled by including this environmental variable (regardless of config type):
|
||||
|
||||
```
|
||||
DEBUG=sonos:*
|
||||
```
|
||||
|
||||
The above changes:
|
||||
|
||||
* Triggers multi-scrobbler to log the raw data recieved from Sonos when a new track/device/service is seen for the first time
|
||||
* Trigger multi-scrobbler to log the reason it considers a play from Sonos as invalid (and will not monitor it)
|
||||
* Logs low-level activity from the Sonos library
|
||||
|
||||
Please include these additional logs when creating a bug report. They are essential to troubleshooting your issue.
|
||||
|
||||
</details>
|
||||
|
||||
### Filtering Activity
|
||||
|
||||
The **optional** `devices` and `groups` related properties found in the config below can be used to include or exclude Sonos devices/groups by name, case-insensitive.
|
||||
|
||||
## Configuration
|
||||
|
||||
You **must** define the IP address of at least one Sonos/accessory in order for Multi-scrobbler to connect to your Sonos system.
|
||||
|
||||
<Config config="SonosSourceConfig" fileContent={JsonConfig} name="sonos">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ---------------------- | --------- | ------- | :--------------------------------------------------------------------- |
|
||||
| `SONOS_HOST` | Yes | | The IP address of any active Sonos device/accessory EX `192.168.0.150` |
|
||||
| `SONOS_DEVICES_ALLOW` | No | | Comma-separated list of Sonos device names to scrobble from |
|
||||
| `SONOS_DEVICES_BLOCK` | No | | Comma-separated list of Sonos device names to disallow scrobbles from |
|
||||
| `SONOS_GROUPS_ALLOW` | No | | Comma-separated list of device group names to scrobble from |
|
||||
| `SONOS_GROUPS_BLOCK` | No | | Comma-separated list of devices group names to disallow scrobbles from |
|
||||
</Config>
|
||||
@@ -23,6 +23,7 @@ A **Source** is a data source that contains information about tracks you are pla
|
||||
| [Kodi](/configuration/sources/kodi) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
|
||||
| [Koito](/configuration/sources/koito-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [Last.fm](/configuration/sources/lastfm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [Libre.fm](/configuration/sources/librefm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [Last.fm (Endpoint)](/configuration/sources/lastfm-endpoint) | [Ingress](./?sourceComm=ingress#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [ListenBrainz](/configuration/sources/listenbrainz-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [ListenBrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint) | [Ingress](./?sourceComm=ingress#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
@@ -32,6 +33,7 @@ A **Source** is a data source that contains information about tracks you are pla
|
||||
| [Musikcube](/configuration/sources/musikcube) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
|
||||
| [Plex](/configuration/sources/plex) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ |
|
||||
| [Rocksky](/configuration/sources/rocksky-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [Sonos](/configuration/sources/sonos) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ |
|
||||
| [Spotify](/configuration/sources/spotify) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ |
|
||||
| [Subsonic-compatible APIs](/configuration/sources/subsonic) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ |
|
||||
| [teal.fm](/configuration/sources/tealfm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
|
||||
@@ -8,17 +8,31 @@ import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import SpotifyConfig from '!!raw-loader!@site/../config/spotify.json.example';
|
||||
import IconWarning from '@theme/Admonition/Icon/Warning';
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Spotify and Automix</summary>
|
||||
|
||||
<DetailsAdmo type="note" summary="Spotify and Automix">
|
||||
If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What-is-Automix/ta-p/5257278) enabled and Spotify uses it for your playlist/queue then MS cannot accurately determine when a track will end. This is because the track is "mixed" in your queue with a shorter play time than its actual length and [Spotify does not report this modified play time in its API.](https://community.spotify.com/t5/Spotify-for-Developers/Wrong-duration-ms-of-track-with-Automix/m-p/5429147) This **does not affect MS's ability to scrobble** from Spotify but it will affect the accuracy of the duration MS reports was played.
|
||||
|
||||
</details>
|
||||
</DetailsAdmo>
|
||||
|
||||
#### Authenticate Spotify with Multi-Scrobbler
|
||||
|
||||
<DetailsAdmo type="warning" summary="Premium Account Required">
|
||||
|
||||
Starting [March 9th 2026](https://developer.spotify.com/blog/2026-02-06-update-on-developer-access-and-platform-security) all applications on the Spotify Developer platform will only continue to work if they are **owned** by a **Spotify Premium** account.
|
||||
|
||||
If your App is not created by a Premium account you will likely recieve a 403 error when trying to use MS with Spotify.
|
||||
|
||||
If you do not have a Spotify Premium account *you can still use Spotify with MS* but it will require you to coordinate with a (friend?) user who *does* have Spotify Premium:
|
||||
|
||||
* Follow the steps below **logged in as the Spotify Premium account user**
|
||||
* After completing App creation (step 4 or 5), navigate to the **User Management** page for the created App
|
||||
* Add **your** (non-premium) account here using your account email/name
|
||||
* Complete the rest of the authentication/configuration process normally
|
||||
|
||||
Assure your friend: **Creating a Spotify Developer app does not give anyone access to the owner's account.** The authentication process within MS is where authorization is given: it requires clicking the link in MS's dashboard and following a separate auth flow. This auth flow only authorizes access to the specific account used in the flow, unrelated to the owner of the developer app.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
To access your Spotify history you must [create a Spotify App](https://developer.spotify.com/dashboard) to get a
|
||||
Client ID/Secret.
|
||||
|
||||
|
||||
@@ -11,26 +11,26 @@ import SubsonicConfig from '!!raw-loader!@site/../config/subsonic.json.example';
|
||||
|
||||
This Source can be used for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) and supports the [`getNowPlaying`](http://www.subsonic.org/pages/api.jsp#getNowPlaying) endpoint (such as [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
|
||||
:::warning[Known Issues]
|
||||
<DetailsAdmo type="warning" summary="Known Issues">
|
||||
|
||||
* Multiple artists are reported as one value and cannot be separated
|
||||
* If using [Airsonic Advanced](https://github.com/airsonic-advanced/airsonic-advanced) the password used (under **Credentials**) must be **Decodable**
|
||||
|
||||
:::
|
||||
</DetailsAdmo>
|
||||
|
||||
:::tip
|
||||
<DetailsAdmo type="tip" summary="Recommended Alternative Sources">
|
||||
|
||||
If your serivce supports scrobbling to **Listenbrainz** (such as Navidrome), considering using a [**Listenbrainz (Endpoint)** Source](/configuration/sources/listenbrainz-endpoint) instead of this one.
|
||||
|
||||
Service-specific scrobble implementations tend to be more accurate and provide more information (multiple artists) compared to the Subsonic API.
|
||||
|
||||
:::
|
||||
</DetailsAdmo>
|
||||
|
||||
Use the optional `usersAllow` property with **File** or **AIO** configuration to restrict scrobbling to a list of defined users.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="SubsonicSourceConfig" fileContent={SubsonicConfig} name="subsonic">
|
||||
<Config config="SubSonicSourceConfig" fileContent={SubsonicConfig} name="subsonic">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|--------------------------------------|
|
||||
| `SUBSONIC_USER` | Yes | | |
|
||||
|
||||
@@ -27,13 +27,84 @@ Follow the **Setup** instructions in the [teal.fm (Client)](/configuration/clien
|
||||
|
||||
## Configuration
|
||||
|
||||
See the [teal.fm (Client)](/configuration/clients/tealfm#setup) configuration for general setup. The only difference for **Source** configuration:
|
||||
See the [teal.fm (Client)](/configuration/clients/tealfm#setup) configuration for general setup. The main differences for **Source** configuration:
|
||||
|
||||
* Cannot be setup with ENV config
|
||||
* [File/AIO config](/configuration?configType=file#configuration-types) must include `"configureAs": "source"`
|
||||
|
||||
### Service Filtering
|
||||
|
||||
`serviceAllow` and `serviceDeny` are lists that can be used to filter what Plays are scrobbles from teal.fm. These correspond to the base domain of the music service where the Play is from, originally. The values in the list can be a subset of the actual service name (they do not need to be exact matches).
|
||||
|
||||
Only one filter can be used at a time. If `serviceAllow` is configured then `serviceDeny` will be ignored.
|
||||
|
||||
If a Play does not have an associated music service it can be allowed/denied using the special keyword `unknown`.
|
||||
|
||||
If you are unsure what services are being used for your scrobbles you can check this by:
|
||||
|
||||
* use [atproto.at](https://atproto.at/) to lookup your DID/handle
|
||||
* View the **Raw Data** for the PDS Collection `fm.teal.alpha.feed.play`
|
||||
* Check the `musicServiceBaseDomain` property for each record
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
Allowing only scrobbles from [plyr.fm](https://plyr.fm/):
|
||||
|
||||
```json title="tealfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myTealSource",
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"identifier": "alice.bsky.social",
|
||||
"appPassword": "twog-phu7-4dhe-y4j3",
|
||||
|
||||
"serviceAllow": ["plyr"],
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Deny scrobbles from last.fm:
|
||||
|
||||
```json title="tealfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myTealSource",
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"identifier": "alice.bsky.social",
|
||||
"appPassword": "twog-phu7-4dhe-y4j3",
|
||||
|
||||
"serviceDeny": ["last"],
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Allow scrobbles from plyr.fm and unknown services:
|
||||
|
||||
```json title="tealfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myTealSource",
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"identifier": "alice.bsky.social",
|
||||
"appPassword": "twog-phu7-4dhe-y4j3",
|
||||
|
||||
"serviceAllow": ["plyr", "unknown"],
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} name="tealfm">
|
||||
:::note
|
||||
You cannot use ENV variables shown in the [teal.fm Client config](/configuration/clients/tealfm#configuration) -- multi-scrobbler assumes teal.fm ENVs are always used for the **client** configuration. You must use the [File or AIO](/configuration?configType=file#configuration-types) config to setup teal.fm as a Source.
|
||||
:::
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :------------------------- | --------- | ------- | ------------------------------------------------------------------------------------------ |
|
||||
| `SOURCE_TEALFM_IDENTIFIER` | Yes | | Your **full** ATProto handle or DID. For Bluesky account this is like `myUser.bsky.social` |
|
||||
| `SOURCE_TEALFM_APP_PW` | Yes | | Bluesky/ATProto network App Password |
|
||||
</Config>
|
||||
@@ -42,6 +42,10 @@ 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.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Example usage in [a file-based config:](/configuration?configType=file#configuration-types)
|
||||
|
||||
```json5 title="config/vlc.json" {10-15}
|
||||
@@ -65,10 +69,16 @@ Example usage in [a file-based config:](/configuration?configType=file#configura
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Vlc Information Reporting
|
||||
|
||||
If you find that VLC is incorrectly reporting track information (in its interface) but multi-scrobbler is not parsing it correctly it may be due to the _name_ of the fields VLC is reporting. mutli-scrobbler uses the most common field name but does not cover all use cases. Before reporting an issue please turn on metadata logging and include output from your logs while it is turned on:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Metadata Logging Configuration</summary>
|
||||
|
||||
```json5 title="config/vlc.json" {9-9}
|
||||
[
|
||||
{
|
||||
@@ -84,6 +94,8 @@ If you find that VLC is incorrectly reporting track information (in its interfac
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="VLCSourceConfig" fileContent={JsonConfig} name="vlc">
|
||||
|
||||
@@ -18,19 +18,19 @@ After installing the [Webscrobbler extension](https://web-scrobbler.com/) open t
|
||||
|
||||
Reload the extension after adding the webhook.
|
||||
|
||||
:::note
|
||||
<DetailsAdmo type="note" summary="URL Types Supported by Webscrobbler">
|
||||
|
||||
* **On Firefox** - Only FQNs (domain.tld), `localhost`, and `127.0.0.1` are supported for API URL due to [firefox requiring https](https://github.com/web-scrobbler/web-scrobbler/issues/4183#issuecomment-1749222006)
|
||||
* **On Chromium-based Browsers** - Any domain will work for API URL
|
||||
* All other browsers are untested
|
||||
|
||||
:::
|
||||
</DetailsAdmo>
|
||||
|
||||
:::warning
|
||||
<DetailsAdmo type="warning" summary="Public-Facing Usage">
|
||||
|
||||
Multi-scrobbler is **not** designed to be publicly accessible from the internet. To use Webscrobbler outside your local network you should be accessing MS through a VPN or through a reverse proxy with some kind of authentication (though this is still not recommended).
|
||||
|
||||
:::
|
||||
</DetailsAdmo>
|
||||
|
||||
### Multiple Users
|
||||
|
||||
@@ -86,7 +86,7 @@ This affects **only** MS's behavior and does not affect the general connector be
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="WebscrobblerSourceConfig" fileContent={JsonConfig} name="webscrobbler">
|
||||
<Config config="WebScrobblerSourceConfig" fileContent={JsonConfig} name="webscrobbler">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|--------------------------------------------------------------------------|
|
||||
| WS_ENABLE | No | | Set to 'true' to enable WS without needing to define other ENVs |
|
||||
|
||||
@@ -115,7 +115,7 @@ Only one of these methods needs to be used.
|
||||
|
||||
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.
|
||||
If it is NOT EXACTLY the same you either need to set MS's [base url](/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>
|
||||
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Scrobble Modification
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
Multi-scrobbler configs support the ability to modify scrobble data in an automated fashion by matching and replacing strings in **title, artists, and album** at many different times in multi-scrobbler's lifecycle.
|
||||
|
||||
### Why?
|
||||
|
||||
You may need to "clean up" data from a Source or before sending to a scrobble Client due to any number of reasons:
|
||||
|
||||
* ID3 tags in your music collection are dirty or have repeating garbage IE `[YourMusicSource.com] My Artist - My Title`
|
||||
* A Source's service often incorrectly adds data to some field IE `My Artist - My Title (Album Version)` when the title should just be `My Title`
|
||||
* An Artist you listen to often is spelled different between a Source and a Client which causes duplicate scrobbles
|
||||
|
||||
In any scenario where a repeating pattern can be found in the data it would be nice to be able to fix it before the data gets downstream or to help prevent duplicate scrobbling. Multi-scrobbler can help you do this.
|
||||
|
||||
## Overview
|
||||
|
||||
### Journey of a Scrobble
|
||||
|
||||
First, let's recap the lifecycle of a scrobble in multi-scrobbler:
|
||||
|
||||
**Sources** are the beginning of the journey for a **Play** (song you've listened to long enough to be scrobblable)
|
||||
|
||||
* A Source finds a new valid **Play**
|
||||
* The Source **compares** this new Play to all the other Plays it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
|
||||
* The Source **discovers** the Play, adds it to Plays it has seen already, and broadcasts the Play should be scrobbled to all Clients
|
||||
|
||||
Scrobble **Clients** listen for discovered Plays from Sources, then...
|
||||
|
||||
* A Client receives a **Play** from a Source
|
||||
* The Client **compares** this Play to all the other scrobbles it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
|
||||
* The Client **scrobbles** the Play downstream to the scrobble service and adds it as a Scrobble it has seen already
|
||||
|
||||
### Lifecyle Hooks
|
||||
|
||||
You'll notice there is a pattern above that looks like this:
|
||||
|
||||
* **Before** data is compared
|
||||
* Data is **compared**
|
||||
* **After** data is compared
|
||||
|
||||
These points, during both Source and Client processes, are when you can hook into the scrobble lifecycle and modify it.
|
||||
|
||||
#### TLDR
|
||||
|
||||
In more concrete terms this is the structure of hooks within a configuration (can be used in any **Source** or **Client**):
|
||||
|
||||
```json5 title="lastfm.json" {10-14}
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {/* ... */},
|
||||
"compare": {/* ... */},
|
||||
"postCompare": {/* ... */}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
##### Hook
|
||||
|
||||
For **Sources**:
|
||||
|
||||
* `preCompare` - modify Play data immediately when received
|
||||
* `compare` - temporarily modify Play data when it is being compared to see if Play was already discovered
|
||||
* `postCompare` - modify Play data before sending to scrobble **Clients**
|
||||
|
||||
For **Clients**:
|
||||
|
||||
* `preCompare` - modify Play data immediately when received
|
||||
* `compare` - temporarily modify Play data when it is being compared to see if it was already scrobbled
|
||||
* `postCompare` - modify Play data before scrobbling it to downstream service and adding to already seen scrobbles
|
||||
|
||||
:::tip
|
||||
|
||||
Keep in mind that modifying Scrobble/Play data earlier in the lifecycle will affect that data at all times later in the lifecycle.
|
||||
|
||||
For example, to modify the track so it's the same anywhere it is processed in multi-scrobbler you only need to modify it in the **Source's** `preCompare` hook because all later processes will receive the data with the modified track.
|
||||
|
||||
:::
|
||||
|
||||
### Modification Parts
|
||||
|
||||
|
||||
Each [**hook**](#hook) (`preCompare` etc...) is an object that specifies what part of the **Play** to modify:
|
||||
|
||||
```json5
|
||||
{
|
||||
"title": [/* ... */],
|
||||
"artists": [/* ... */],
|
||||
"album": [/* ... */]
|
||||
}
|
||||
```
|
||||
|
||||
##### Expression
|
||||
|
||||
and then a **list** what pattern/replacements (expressions) to use for the modification by using either simple strings or `search-replace` objects:
|
||||
|
||||
```json5
|
||||
[
|
||||
"badTerm", // remove all instances of 'badTerm'
|
||||
{
|
||||
"search": "anotherBadTerm", // and also match all instances of 'anotherBadTerm'
|
||||
"replace": "goodTerm" // replace with the string 'goodTerm'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Putting it all together:
|
||||
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
"badTerm",
|
||||
{
|
||||
"search": "badTerm",
|
||||
"replace": "goodTerm"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
If the value of the field (title, an artist, album) is an empty string after transforming then the field is **removed.**
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
Modifications can also be applied to **all Sources** or **all Clients** when using the [AIO Config](./configuration.mdx?configType=aio#configuration-types) `config.json` by setting `playTransform` in `sourceDefaults` or `clientDefaults`:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": { // will apply playTransform to all sources
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
"(Album Version)"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources": [/* ... */],
|
||||
"clients": [/* ... */]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
:::
|
||||
|
||||
#### Compare Hook
|
||||
|
||||
The `compare` [hook](#hook) is slightly different than `preCompare` and `postCompare`. It consists of an object where you define which side(s) of the comparison should be modified. It also **does not modify downstream data!** Instead, the modifications are made only for use in the comparison.
|
||||
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"compare": {
|
||||
"candidate": {/* ... */}, // modify the "new" Play being compared
|
||||
"existing": {/* ... */}, // modify all "existing" Play/Scrobbles the new Play is being compared against
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Regular Expressions
|
||||
|
||||
In addition to plain strings [expressions](#expression) that are matched and removed you can also use Regular Expressions. Write your regex like you normally would, but as a string, and it'll automatically be parsed:
|
||||
|
||||
```json5
|
||||
[
|
||||
"/^\(\w+.com)/i", // matches any string that starts with '(YourMusic.com)' and removes it
|
||||
{
|
||||
"search": "/^\(\w+.com)/i", // matches any string that starts with '(YourMusic.com)'
|
||||
"replace": "[MySite.com]" // replace with the string '[MySite.com]'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `replace` property uses javascript's [`replace()` function and so can use any special string characters.](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement)
|
||||
|
||||
### Conditional Modification
|
||||
|
||||
#### "When" Condition
|
||||
|
||||
Top-level hooks **and** individual rules also support a `when` key for testing **if they should be run.**
|
||||
|
||||
The `when` key is similar to a normal [modification](#modification-parts) except:
|
||||
|
||||
* the keys accept a single string instead of an array
|
||||
* the `when` key data is an array instead of a single object
|
||||
|
||||
All parts of an individual `when` clause must test true to "pass" but if **any** `when` clauses pass the hook/rule is processed. Example `when` data:
|
||||
|
||||
```json5
|
||||
{
|
||||
"when": [
|
||||
{
|
||||
"artist": "Elephant Gym", // both of these must match the Play object (AND)
|
||||
"album": "Dreams" // both of these must match the Play object (AND)
|
||||
},
|
||||
// OR
|
||||
{
|
||||
"title": "/(Remastered)$/", // both of these must match the Play object (AND)
|
||||
"album": "Various Artists" // both of these must match the Play object (AND)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
More succinctly:
|
||||
|
||||
* All parts (`artist` `album` `title`) of a `when` are `AND` conditions
|
||||
* All part-objects in the `when` array are `OR` conditions
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example of top-level hook with when condition</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
// IF the artist is Elephant Gym
|
||||
// THEN Run preCompare hook ELSE skip this hook
|
||||
//
|
||||
// Run search-replace on album
|
||||
// Run regex title remove
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"when": [
|
||||
{
|
||||
"artist": "/Elephant Gym/"
|
||||
}
|
||||
],
|
||||
"album": [
|
||||
{
|
||||
"search": "Dreams",
|
||||
"replace": "夢境"
|
||||
}
|
||||
],
|
||||
"title": ["/\s\-\s滾石40\s滾石撞樂隊\s40團拚經典(.+)$/i"]
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example of individual rule with when condition</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
// Always run preCompare
|
||||
//
|
||||
// On search-replace in title...
|
||||
// IF artist matches "Elephant Gym"
|
||||
// THEN Run regex search-replace ELSE skip this rule
|
||||
//
|
||||
// Run live|remastered regex remove
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
{
|
||||
"search": "/\\s\\-\\s滾石40\\s滾石撞樂隊\\s40團拚經典(.+)$/i",
|
||||
"replace": "",
|
||||
"when": [
|
||||
{
|
||||
"artist": "/Elephant Gym/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/(\\s\\-\\s|\\s)(feat\\.(.+)|live|remastered(.+))$/i"
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Top-level Hook array
|
||||
|
||||
Top-level hooks can also be an array of hooks. This makes creating multiple scenarios for top-level `when`-gated hooks easier. All hooks in the array will be run (assuming their `when`'s pass, if they exist) and their **input will be the Play object output of the previous hook in the array.**
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
// first lifecycle hook of preCompare to run
|
||||
{
|
||||
"title": [
|
||||
{
|
||||
"search": "something",
|
||||
"replace": "else unique"
|
||||
}
|
||||
]
|
||||
},
|
||||
// second lifecycle hook of preCompare to run
|
||||
{
|
||||
"title": [
|
||||
{
|
||||
"search": "else unique",
|
||||
"replace": "very demure"
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Logging
|
||||
|
||||
MS can log the output of hook transformations if/when they occur. In the `playTransform` object of a Source/Client config use `log`:
|
||||
|
||||
* `"log": true` => Output original play + final transformed output of last hook in the array
|
||||
* `"log": "all"` => Output original play + final transformed output of **each** hook in the array
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "myThing",
|
||||
"data": {/*...*/},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {/*...*/},
|
||||
"log": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Remove phrase from Title in all new Plays
|
||||
|
||||
Removes the phrase `(Album Version)` from the Title of a Play
|
||||
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
"(Album Version)"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
### Remove all parenthesized content from the end of a title
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"compare": {
|
||||
"candidate": {
|
||||
"title": [
|
||||
"/(\(.+\))\s*$/"
|
||||
]
|
||||
},
|
||||
"existing": {
|
||||
"title": [
|
||||
"/(\(.+\))\s*$/"
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
### Rename misspelled artist in all new Plays
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"artists": [
|
||||
{
|
||||
"search": "Boz Skaggs",
|
||||
"replace": "Boz Scaggs"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Remove "Various Artists" albums in all new Plays
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"album": [
|
||||
{
|
||||
"search": "Various Artists",
|
||||
"replace": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</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>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"label": "Enhance Scrobbles",
|
||||
"link": {
|
||||
"type": "doc",
|
||||
"id": "configuration/transforms/transforms"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
||||
---
|
||||
title: Native Stage
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
The **Native** [Stage](/configuration/transforms#stage) uses [built-in heuristics](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/tests/plays/playParsing.test.ts) to try to extract Artists from Play artist/track data.
|
||||
|
||||
This Stage is most useful for Sources that report limited data such as:
|
||||
|
||||
* [Subsonic](/configuration/sources/subsonic) - Reports Artists as a single string
|
||||
|
||||
A non-exhaustive list of heuristics:
|
||||
|
||||
* Splits artists in artist string using common delimiters EX `Foo Artist, Bar Guy, Baz Band - My Cool Song`
|
||||
* Does not split artists with `&` in name, if other delimiters are present
|
||||
* Does not split artist name when only one delimiter is present
|
||||
* Splits artists on common joiner phrases (ft. feat. vs. etc...)
|
||||
* Extracts artists from Play title using joiner phrases EX `My Cool Song (feat. SomeGuy)`
|
||||
|
||||
A practical example of what the Native Stage will do to Play data:
|
||||
|
||||
This Scrobble from [Subsonic](/configuration/sources/subsonic)
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Endless Possibility (feat. Wheatus)",
|
||||
"artists": ["Bowling For Soup & Punk Rock Factory"]
|
||||
}
|
||||
```
|
||||
|
||||
Will be transformed into this
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Endless Possibility",
|
||||
"artists": ["Bowling For Soup", "Punk Rock Factory", "Wheatus"]
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Available properties for [Stage Configuration](/configuration/transforms#configuring-stages):
|
||||
|
||||
|
||||
* `delimitersExtra` - A list of string characters that should be considered delimiters for artists **in addition to** multi-scrobbler's default list (`, / \ `)
|
||||
* `delimiters` - A list of string characters that should be considered delimiters for artists.
|
||||
* **Replaces** all delimiters (MS not use any defaults, only what you give it)
|
||||
* `artistsIgnore` - a list of strings and/or regular expressions. Any monolothic artist string that matches from the list _will not be modified._
|
||||
* `artistsParseFrom` a list of the properties that should be used to try to extract artists. Can be `artists` `title` or both. Defaults to both when not provided in options ( `["artists", "title"]` )
|
||||
* When `artists` is present it tries to extract additional artists from artist strings
|
||||
* When `title` is present it tries to extract artists from ft. feat. vs. etc... found in the track title
|
||||
* Importantly, if `artists` is _not_ present in `artistsParseFrom` then _no artists_ are used at all (only those from `title`, if present)
|
||||
* `artistsParseMonolithicOnly` - boolean value, defaults to `true`. When `true` native tranformer will only attempt to extract artists if the scrobble data has _only one string for artist_ (pre-transform)
|
||||
* This means that, for Sources like Spotify/Jellyfin/etc. that provide proper lists of artists in their data _and the list has more than one string_, it will not try to extract artists from their strings
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example of the behavior for `artistsParseFrom`</summary>
|
||||
|
||||
```
|
||||
The Foos, The Bars - My Cool Track (ft. Frank)
|
||||
```
|
||||
|
||||
Config => extracted artists
|
||||
|
||||
```
|
||||
"artistsParseFrom": ["artists", "title"] => The Foos, The Bars, Frank
|
||||
"artistsParseFrom": ["artists"] => The Foos, The Bars
|
||||
"artistsParseFrom": ["title"] => Frank
|
||||
"artistsParseFrom": [] =>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Rules
|
||||
|
||||
Each [Rule](/configuration/transforms#stage-rules) should be either a boolean, specifying if the transformed data should be used for this field, or a [`when` condition.](/configuration/transforms#conditional-modification):
|
||||
|
||||
```json5
|
||||
{
|
||||
"type": "native",
|
||||
// ...
|
||||
"title": false, // will not apply any changes to Play title
|
||||
"artists": {
|
||||
"when": {/* ... */}, // will only apply changes to Play artists if "when" is satisfied
|
||||
/* ... */
|
||||
},
|
||||
"album": true // will always apply changes to Play album
|
||||
}
|
||||
```
|
||||
|
||||
If a rule is not present then multi-scrobbler defaults it to `true`.
|
||||
|
||||
### Default Stage
|
||||
|
||||
For your convenience, if you do not define any Native [Stage Configurations](/configuration/transforms#configuring-stages) then multi-scrobbler automatically builds one using the defaults shown in [configuration](#configuration).
|
||||
|
||||
This can be used by omitting the `name` in your [modification Stage](/configuration/transforms#stage).
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native",
|
||||
// do not include name, the default native stage will be used
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
## ENV Configuration
|
||||
|
||||
The default configuration shown above can also be applied using [ENV Config](/configuration?configType=env#configuration-types) for individual Sources/Clients.
|
||||
|
||||
Use ENV `*_TRANSFORMS=native` on each Source/Client you wish to apply this stage to. This applies the stage in the [`preTransform` Hook](/configuration/transforms#lifecycle-hooks) with all [Rules](#rules) enabled.
|
||||
|
||||
The `*` stands for the prefix used for each Source/Client's ENV keys. Refer to the individual Source/Client Configuration sections to find this. Example
|
||||
|
||||
* All [Subsonic Source](/configuration/sources/subsonic/) ENVs look like `SUBSONIC_USER=myuser` etc...
|
||||
* Use `SUBSONIC_TRANSFORMS=native`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example Full Docker Deploy with ENV Configuration</summary>
|
||||
|
||||
Using [Jellyfin + Maloja](/quickstart#create-docker-compose-file) example from Quickstart:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- JELLYFIN_URL=192.168.0.110:8096
|
||||
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b
|
||||
- JELLYFIN_USER=MyUser
|
||||
- JELLYFIN_TRANSFORMS=native # applies native Stage to preTransform of Jellyfin source
|
||||
|
||||
- MALOJA_URL=http://192.168.0.100:42010 # maloja receives enhanced scrobble from Jellyfin
|
||||
- MALOJA_API_KEY=myApiKey
|
||||
|
||||
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
ports:
|
||||
- "9078:9078"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Examples
|
||||
|
||||
### Parse only artists string using a custom delimiter
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "native",
|
||||
"name": "MyNativeTransformer",
|
||||
"defaults": {
|
||||
// default delimiters when this Stage is used in a hook
|
||||
"delimiters": [
|
||||
"•"
|
||||
],
|
||||
// default delimiters when this Stage is used in a hook
|
||||
"artistsParseFrom": ["artists"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native",
|
||||
"name": "MyNativeTransformer"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
### Don't parse specific artists
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "native",
|
||||
"name": "MyNativeTransformer",
|
||||
"defaults": {
|
||||
"artistsIgnore": [
|
||||
"Crosby, Stills, Nash & Young",
|
||||
"Polo & Pan"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native",
|
||||
"name": "MyNativeTransformer"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
### Extract Artists from Title but do not modify Title
|
||||
|
||||
In the scenario where you want to extract Artists from Track titles with joiners like `My Cool Track (feat. Foobar)` but want to keep the title as-is:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
No additional [stage configuration](#configuration) is required so we can use the [default Stage](#default-stage).
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native",
|
||||
"title": false // do not modify title
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
@@ -0,0 +1,594 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Enhancing Scrobbles
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
Multi-scrobbler configs support the ability to enhance scrobble data in an automated fashion by matching and replacing strings in **title, artists, and album** at many different times in multi-scrobbler's lifecycle.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Why Would I Do This?</summary>
|
||||
|
||||
You may need to "clean up" data from a Source or before sending to a scrobble Client due to any number of reasons:
|
||||
|
||||
* ID3 tags in your music collection are dirty or have repeating garbage IE `[YourMusicSource.com] My Artist - My Title`
|
||||
* A Source's service often incorrectly adds data to some field IE `My Artist - My Title (Album Version)` when the title should just be `My Title`
|
||||
* An Artist you listen to often is spelled different between a Source and a Client which causes duplicate scrobbles
|
||||
|
||||
In any scenario where a repeating pattern can be found in the data it would be nice to be able to fix it before the data gets downstream or to help prevent duplicate scrobbling. Multi-scrobbler can help you do this.
|
||||
|
||||
</details>
|
||||
|
||||
## Journey of a Scrobble
|
||||
|
||||
First, let's recap the lifecycle of a scrobble in multi-scrobbler:
|
||||
|
||||
**Sources** are the beginning of the journey for a **Play** (song you've listened to long enough to be scrobblable)
|
||||
|
||||
* A Source finds a new valid **Play**
|
||||
* The Source **compares** this new Play to all the other Plays it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
|
||||
* The Source **discovers** the Play, adds it to Plays it has seen already, and broadcasts the Play should be scrobbled to all Clients
|
||||
|
||||
Scrobble **Clients** listen for discovered Plays from Sources, then...
|
||||
|
||||
* A Client receives a **Play** from a Source
|
||||
* The Client **compares** this Play to all the other scrobbles it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
|
||||
* The Client **scrobbles** the Play downstream to the scrobble service and adds it as a Scrobble it has seen already
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
You'll notice there is a pattern above that looks like this:
|
||||
|
||||
* **Before** data is compared
|
||||
* Data is **compared**
|
||||
* **After** data is compared
|
||||
|
||||
These points, during both Source and Client processes, are when you can hook into the scrobble lifecycle and modify it.
|
||||
|
||||
##### TLDR
|
||||
|
||||
In more concrete terms this is the structure of hooks within a configuration (can be used in any **Source** or **Client**):
|
||||
|
||||
```json5 title="lastfm.json" {10-14}
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [/* ... */],
|
||||
"compare": [/* ... */],
|
||||
"postCompare": [/* ... */]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Hook
|
||||
|
||||
For **Sources**:
|
||||
|
||||
* `preCompare` - modify Play data immediately when received
|
||||
* `compare` - temporarily modify Play data when it is being compared to see if Play was already discovered
|
||||
* `postCompare` - modify Play data before sending to scrobble **Clients**
|
||||
|
||||
For **Clients**:
|
||||
|
||||
* `preCompare` - modify Play data immediately when received
|
||||
* `compare` - temporarily modify Play data when it is being compared to see if it was already scrobbled
|
||||
* `postCompare` - modify Play data before scrobbling it to downstream service and adding to already seen scrobbles
|
||||
|
||||
<DetailsAdmo type="tip" summary="Transformed Scrobbles Are Permanently Modified">
|
||||
|
||||
Keep in mind that modifying Scrobble/Play data earlier in the lifecycle will affect that data at all times later in the lifecycle (except when using the **compare** hook).
|
||||
|
||||
For example, to modify the track so it's the same anywhere it is processed in multi-scrobbler you only need to modify it in the **Source's** `preCompare` hook because all later processes will receive the data with the modified track.
|
||||
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
:::tip[TLDR]
|
||||
|
||||
**To modify a scrobble coming from one of your Sources use the `preCompare` hook.**
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Using `compare` hook</summary>
|
||||
|
||||
The `compare` [hook](#hook) is slightly different than `preCompare` and `postCompare`. It consists of an object where you define which side(s) of the comparison should be modified. It also **does not modify downstream data!** Instead, the modifications are made only for use in the comparison.
|
||||
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"compare": [
|
||||
{
|
||||
"candidate": {/* ... */}, // modify the "new" Play being compared
|
||||
"existing": {/* ... */}, // modify all "existing" Play/Scrobbles the new Play is being compared against
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Modification Stage {#stage}
|
||||
|
||||
Each [**hook**](#hook) is made up of one or more **Stages**. A Stage is a self-contained, unique way of enhancing or modifying the Play data. Some examples of a Stage:
|
||||
|
||||
* The [User](/configuration/transforms/user) Stage allows a user to define search-and-replace terms for Artist/Title/Album
|
||||
* The [Native](/configuration/transforms/native) Stage uses MS's built-in heuristics to extract Artists from a single Artist string
|
||||
* The [Musicbrainz](/configuration/transforms/musicbrainz) Stage tries to match Play data with the Musicbrainz database and to standardize the Artist/Title/Album data
|
||||
|
||||
Each Stage in a Hook receives Play data from the previous Stage.
|
||||
|
||||
Within a hook, each Stage minimally consists of a `type` to identify what Stage it is along with any other data specific to that stage:
|
||||
|
||||
```json5
|
||||
{
|
||||
"type": "native"
|
||||
// optional, stage specific data here...
|
||||
}
|
||||
```
|
||||
|
||||
### Configuring Stages {#configuring-stages}
|
||||
|
||||
Stages may be globally configured using [AIO Config](/configuration?configType=aio#configuration-types) `config.json` file in the top-level `transformers` block.
|
||||
|
||||
Each Stage consists of:
|
||||
|
||||
* `type` the type of Stage
|
||||
* `name` a unique name for the Stage, to be (potentially) used with hooks
|
||||
* `defaults` - An object defining default configuration for this stage, when used in a Hook.
|
||||
* `data` - An object containing any data required to initially configure the stage itself (Example: API URL, username, password, etc...)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "native",
|
||||
"name": "MyNativeTransformer",
|
||||
"defaults": {
|
||||
// default delimiters when this Stage is used in a hook
|
||||
"delimiters": [
|
||||
"•"
|
||||
],
|
||||
// default delimiters when this Stage is used in a hook
|
||||
"artistsParseFrom": ["artists"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native"
|
||||
"name": "MyNativeTransformer" // when "name" is not defined, uses first found "native" transformer
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
Multiple stages of the same type may be configured, allowing you to define several sets of default behavior.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "native",
|
||||
"name": "DotTransformer",
|
||||
"defaults": {
|
||||
"delimiters": [
|
||||
"•"
|
||||
],
|
||||
"artistsParseFrom": ["artists"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "native",
|
||||
"name": "TitleOnly",
|
||||
"defaults": {
|
||||
// extracts and uses *only* artists found in title string
|
||||
"artistsParseFrom": ["title"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native"
|
||||
"name": "DotTransformer"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In a [VLC](/configuration/sources/vlc) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="vlc.json"
|
||||
[
|
||||
{
|
||||
"name": "MyVLC",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native"
|
||||
"name": "TitleOnly"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Overriding Configuration
|
||||
|
||||
The default configuration you set for your Stage may be overridden in any usage of the Stage within a Hook.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "native",
|
||||
"name": "MyNativeTransformer",
|
||||
"defaults": {
|
||||
// default delimiters when this Stage is used in a hook
|
||||
"delimiters": [
|
||||
"•"
|
||||
],
|
||||
// default delimiters when this Stage is used in a hook
|
||||
"artistsParseFrom": ["artists"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native"
|
||||
"name": "MyNativeTransformer",
|
||||
// overrides property from "defaults"
|
||||
"artistsParseFrom": ["artists", "title"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
### Rules for Play Data {#stage-rules}
|
||||
|
||||
Each [Stage](#stage) may specify whether it should apply the resulting transformation to different parts of the Play data by specifying `title`, `artists` and/or `album` in the Stage object.
|
||||
|
||||
```json5
|
||||
{
|
||||
"type": "native",
|
||||
// ...
|
||||
"title": false, // will not apply any changes to Play title
|
||||
"artists": {
|
||||
"when": {/* ... */}, // will only apply changes to Play artists if "when" is satisfied
|
||||
/* ... */
|
||||
},
|
||||
"albumArtists": true, // will always apply changes to Play album artists
|
||||
"album": true, // will always apply changes to Play album
|
||||
"duration": true, // will always apply changes to Play duration (length of track)
|
||||
"meta": true, // will always apply changes to Play meta (MBIDs, spotify links, etc...)
|
||||
}
|
||||
```
|
||||
|
||||
The actual value of each property may be different for each Stage. Check the docs for the Stage you want to use to see its usage of each.
|
||||
|
||||
Generically, though, each property may be some value **or** an object combining a [`when` condition](#conditional-modification) and that value.
|
||||
|
||||
**If none of the properties are specified in the stage then it's assumed all transformed data should be used.**
|
||||
|
||||
:::note
|
||||
|
||||
Specifying these Rules is **not** the same as [configuring the Stage](#configuring-stages). Rules only determine if the *result* of the transformation should be used (replace) the existing Play Data.
|
||||
|
||||
:::
|
||||
|
||||
## Stage Flow Control {#flow-control}
|
||||
|
||||
Stages may be optionally configured to **continue** to the next Stage or **stop** (end early) all subsequent Stages, based on the outcome of the currently running Stage.
|
||||
|
||||
These three properties can be added to the [Modification Stage](#stage) data, alongside [Rules](#stage-rules):
|
||||
|
||||
* `onSuccess` (default `continue`) - If the Stage successfully finishes processing
|
||||
* `onFailure` (default `stop`) - If the Stage encounters an error while processing, or otherwise fails to achieve the transformation result
|
||||
* `onSkip` (default `continue`) - If the Stage does not process the Play data due to stage-level [`when`](#conditional-modification) or other stage-specific skip conditions
|
||||
|
||||
:::tip
|
||||
|
||||
The default behaviors for Flow Control are the same as you would intuitively think Stages should run IE the next Stage you have defined runs if the current Stage does not fail.
|
||||
|
||||
Specifying Flow Control is only necessary if the above assumptions are not true for your scenario.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Default Flow Control Example</summary>
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
// if this stage is successful, native is run
|
||||
// if this stage fails, native does not run
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB"
|
||||
},
|
||||
{
|
||||
"type": "native",
|
||||
"name": "MyNative"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Customized Flow Control Example</summary>
|
||||
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
// if musicbrainz is successful then do NOT run native,
|
||||
// only run native if musicbrainz fails to find a match (onFailure)
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB"
|
||||
"onSuccess": "stop",
|
||||
"onFailure": "continue"
|
||||
},
|
||||
{
|
||||
"type": "native",
|
||||
"name": "MyNative"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Conditionally Run {#conditional-modification}
|
||||
|
||||
[Stages](#stage) within a [Hook](#hook), and [Rules](#stage-rules) within each Stage, support a `when` object for testing **if they should be run.**
|
||||
|
||||
The `when` object may have propertes for rule. Each property may be a string or regular expression. The value of the property is used to match the **pre-transformation** values from Play data.
|
||||
|
||||
All parts of an individual `when` clause must test true to "pass" but if **any** `when` clauses pass the Stage/Rule is processed.
|
||||
|
||||
If the `when` test does not pass then the Stage is [**skipped (`onSkip`)**](#flow-control).
|
||||
|
||||
```json5
|
||||
{
|
||||
"when":
|
||||
{
|
||||
"artist": "Elephant Gym", // Play must have an artist matching "Elephant Gym" (AND)
|
||||
"album": "Dreams" // Play object must have an album matching "Dreams" (AND)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
"when": [
|
||||
{
|
||||
"artist": "Elephant Gym", // Play must have an artist matching "Elephant Gym" (AND)
|
||||
"album": "Dreams" // Play object must have an album matching "Dreams" (AND)
|
||||
},
|
||||
// OR
|
||||
{
|
||||
"title": "/(Remastered)$/", // Play title must match regular expression (AND)
|
||||
"album": "Various Artists" // Play album must match "Various Artists" (AND)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
More succinctly:
|
||||
|
||||
* All parts (`artist` `album` `albumArtist` `duration` `meta` `title`) of a `when` are `AND` conditions
|
||||
* All part-objects in the `when` array are `OR` conditions
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example of Stage with `when` condition</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
// IF the artist is Elephant Gym
|
||||
// THEN Run native stage
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "native",
|
||||
"when": [
|
||||
{
|
||||
"artist": "/Elephant Gym/"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example of individual rule with when condition</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
// Always run native Stage
|
||||
//
|
||||
// IF artist matches "Elephant Gym"
|
||||
// THEN use result of native stage for "artists" of Play data
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"type": "native",
|
||||
"artists":
|
||||
{
|
||||
"when": [
|
||||
{
|
||||
"artist": "/Elephant Gym/"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Logging
|
||||
|
||||
MS can log a diff of Stage transformations if/when they occur. In the `playTransform` object of a Source/Client config use `log`:
|
||||
|
||||
* `"log": true` => Output original play + final transformed output of last Stage in the array
|
||||
* This can also be enabled by using [`DEBUG_MODE=true`](/configuration/#debug-mode)
|
||||
* `"log": "all"` => Output original play + final transformed output of **each** Stage in the array
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "myThing",
|
||||
"data": {/*...*/},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {/*...*/},
|
||||
"log": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
The output shows the diff between the previous stage (or original Play) and the current stage. In docker logs this is highlighted with diff syntax:
|
||||
|
||||
```diff
|
||||
[2025-12-17 08:53:10.467 -0500] DEBUG : [App] [Sources] [Lastfm - mylfm] [Play Transform] [preCompare] [VLDJJo] Transform Diff
|
||||
- Original
|
||||
+ musicbrainz-MyMB
|
||||
|
||||
Title : Demons Theme Part II (original 12" mix)
|
||||
Artists : LTJ Bukem
|
||||
Album Artists: (None)
|
||||
- Album : Producer 05: Rarities (Original 12" Version)
|
||||
+ Album : Producer 05: Rarities (original 12" version)
|
||||
Meta :
|
||||
* brainz-album: 36759a8a-d3df-47da-a236-60f84fdc0cab
|
||||
+ * brainz-artist: 28c1b7b7-355a-48b1-b2c4-75b8eb8080ef
|
||||
+ * brainz-track: a0d51240-7ef1-4676-882e-be9f354075cb
|
||||
+ * brainz-releaseGroup: e0cce74f-abcc-343b-9390-1673e4d57ce7
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Examples
|
||||
|
||||
See **Examples** sections in specific Stage docs (also in the sidebar):
|
||||
|
||||
* [User Stage](/configuration/transforms/user#examples)
|
||||
* [Native Stage](/configuration/transforms/native#examples)
|
||||
* [Musicbrainz Stage](/configuration/transforms/musicbrainz#examples)
|
||||
@@ -0,0 +1,311 @@
|
||||
---
|
||||
title: User Stage
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
The **User** [Stage](/configuration/transforms#stage) uses [**search-and-replace** expressions](#search-and-replace-expression), provided by you, to modify/replace parts of Play data.
|
||||
|
||||
This Stage is most useful for correcting individual instances of bad data, or patterns, in your known data. Example scenarios:
|
||||
|
||||
* Removing `(Album Version)` from all Titles
|
||||
* Removing `Various Artists` from Artist data
|
||||
* Correcting spelling mistakes for individual Artist names
|
||||
|
||||
The user [Stage `type`](/configuration/transforms#stage) is `user`.
|
||||
|
||||
## Configuration
|
||||
|
||||
All [Stage Configuration](/configuration/transforms#configuring-stages) is done using [**search-and-replace** expressions](#search-and-replace-expression) inside individual [rules](#rules). Default configuration for all Rules can still be done using Stage `defaults`.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "user",
|
||||
"name": "Normalizer",
|
||||
"defaults": {
|
||||
"title": [
|
||||
"(Album Version)"
|
||||
],
|
||||
"artists": [
|
||||
"Various Artists"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Default Stage
|
||||
|
||||
For your convenience, if you do not define any User [Stage Configurations](/configuration/transforms#configuring-stages) then multi-scrobbler automatically builds an empty one for you.
|
||||
|
||||
This can be used by omitting the `name` in your [modification Stage](/configuration/transforms#stage).
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "user",
|
||||
// do not include name, the default user stage will be used
|
||||
"title": [
|
||||
// removes badterm from track title
|
||||
"badterm"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
### Search-And-Replace Expression
|
||||
|
||||
A Search-And-Replace Expression can be a plain string that matches a literal, then removes it:
|
||||
|
||||
```
|
||||
Expression: "badTerm"
|
||||
|
||||
"this is badTerm cool string" => "this is a cool string"
|
||||
```
|
||||
|
||||
or a regular expression that matches and removes the match:
|
||||
|
||||
```
|
||||
Expression: "/bad\w+/i"
|
||||
|
||||
"this is badSomething cool string" => "this is a cool string"
|
||||
```
|
||||
|
||||
Or it may be an object that specifies what to match (using either plain string or regular expression) and what to replace it with:
|
||||
|
||||
```json5
|
||||
{
|
||||
"search": "anotherBadTerm", // match all instances of 'anotherBadTerm'
|
||||
"replace": "goodTerm" // replace with the string 'goodTerm'
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
"this is anotherBadTerm cool string" => "this is goodTerm cool string"
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
"search": "/^\(\w+.com)/i", // matches any string that starts with EX '(YourMusic.com)'
|
||||
"replace": "[MySite.com]" // replace with the string '[MySite.com]'
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
"(Foo.com) this is a cool string" => "[MySite.com] this is a cool string"
|
||||
```
|
||||
|
||||
The `replace` property uses javascript's [`replace()` function and so can use any special string characters.](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement)
|
||||
|
||||
### Rules
|
||||
|
||||
Each [Rule](/configuration/transforms#stage-rules) must be an array of [search-and-replace expressions](#search-and-replace-expression):
|
||||
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"type": "user",
|
||||
"preCompare": [
|
||||
{
|
||||
"title": [
|
||||
// removes "badTerm" from title
|
||||
"badTerm",
|
||||
{
|
||||
// removes "fooTerm" from title and replaces it with "barTerm"
|
||||
"search": "fooTerm",
|
||||
"replace": "barTerm"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
If the value of the field (title, an artist, album) is an empty string after transforming then the field is **removed.**
|
||||
|
||||
:::
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
### Usage with `when` condition
|
||||
|
||||
Using `when` for [Conditional Modification](/configuration/transforms#conditional-modification)
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5
|
||||
// On search-replace in title...
|
||||
// IF artist matches "Elephant Gym"
|
||||
// THEN Run regex search-replace ELSE skip this rule
|
||||
//
|
||||
// Run live|remastered regex remove on title
|
||||
{
|
||||
"title": [
|
||||
{
|
||||
"search": "/\\s\\-\\s滾石40\\s滾石撞樂隊\\s40團拚經典(.+)$/i",
|
||||
"replace": "",
|
||||
"when": [
|
||||
{
|
||||
"artist": "/Elephant Gym/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/(\\s\\-\\s|\\s)(feat\\.(.+)|live|remastered(.+))$/i"
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Remove phrase from Title in all new Plays
|
||||
|
||||
Removes the phrase `(Album Version)` from the Title of a Play
|
||||
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
"title": [
|
||||
"(Album Version)"
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
### Remove all parenthesized content from the end of a title
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5
|
||||
{
|
||||
"compare": {
|
||||
"candidate": {
|
||||
"title": [
|
||||
"/(\(.+\))\s*$/"
|
||||
]
|
||||
},
|
||||
"existing": {
|
||||
"title": [
|
||||
"/(\(.+\))\s*$/"
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Rename misspelled artist in all new Plays
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5
|
||||
{
|
||||
"artists": [
|
||||
{
|
||||
"search": "Boz Skaggs",
|
||||
"replace": "Boz Scaggs"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Remove "Various Artists" albums in all new Plays
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5
|
||||
{
|
||||
"album": [
|
||||
{
|
||||
"search": "Various Artists",
|
||||
"replace": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</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>
|
||||
```json
|
||||
{
|
||||
"artists": [
|
||||
{
|
||||
"search": "(.*?)(?<delim>\\s*\\/\\s*)(.*$)",
|
||||
"replace": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
</details>
|
||||
@@ -178,3 +178,15 @@ For more refer to the TS documentation for `PlayObject` or [`AmbPlayObject`](htt
|
||||
|
||||
* [Source Development and Tutorial](dev-source.md)
|
||||
* [Client Development and Tutorial](dev-client.md)
|
||||
|
||||
## Profiling
|
||||
|
||||
Run tsx with inspect args
|
||||
|
||||
```
|
||||
NODE_ENV=production node node_modules/.bin/tsx --inspect --heap-prof src/backend/index.ts
|
||||
```
|
||||
|
||||
Use `chrome://inspect` from a chromium-based browser and attach to the running process, usually `localhost:9229`
|
||||
|
||||
From the opened DevTools window use Performance or Memory to profile the running process.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: 'Help'
|
||||
---
|
||||
|
||||
## Getting Useful Data
|
||||
|
||||
### Logs
|
||||
|
||||
Multi-scrobbeler has a real-time log shown in the dashboard. These are the same logs you would find in docker logs. In the dashboard, logs can be filtered by LEVELS to give you a better view of errors/warnings.
|
||||
|
||||
When creating an issue please include all logs at the lowest level possible (DEBUG) to help provide context for your issue. You should try to include logs that occurred *before* and *after* your issue/error occurs as this can help determine if MS was behaving normally.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
If possible, turn on [**Debug Mode**](/configuration#debug-mode) and replicate your issue. Debug Mode enables *much more* logging that can reveal additional data for troubleshooting.
|
||||
|
||||
This mode will make your logs very noisy, though, so it's recommended to turn it off after collecting logs.
|
||||
|
||||
### Copy Play Debug Data
|
||||
|
||||
From the dashboard, each Source/Client has a few details page links:
|
||||
|
||||
<img src={require('/img/details.jpg').default} height="400"/>
|
||||
|
||||
Opening the details page for the Source/Client reveals a lists all of the Plays that were discovered (Sources) or scrobbled (Clients).
|
||||
|
||||
Click on the Debug icon to copy debugging data relevant to that Play. This data should be included in any issue you create.
|
||||
|
||||
<img src={require('/img/debug_button.jpg').default} height="400"/>
|
||||
|
||||
To include this data in an issue either paste the copied data into a text file and save as `debugdata.json`, attaching to the issue.
|
||||
|
||||
Or directly paste the copied data into an issue/comment using [**fenced code blocks**](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#fenced-code-blocks) markdown to preserve the structure like:
|
||||
|
||||
````
|
||||
This is the contents of my comment/issue text on github.
|
||||
|
||||
I am explaining my issue here. Here is the debug data:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": {
|
||||
"aProperty: 123,
|
||||
"foo": "bar"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Thank you for your time.
|
||||
|
||||
````
|
||||
|
||||
## Finding Help
|
||||
|
||||
### FAQ
|
||||
|
||||
Check the [FAQ](/FAQ) to see if your issue is a known issue or a commonly-experienced issue with Multi-Scrobbler. The FAQ also includes fixes or ways to help troubleshoot these common issues.
|
||||
|
||||
### Existing Issues
|
||||
|
||||
On the github repository for Multi-Scrobbler, search [existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions](https://github.com/FoxxMD/multi-scrobbler/discussions) to see if anyone else has had this issue and if it has already been solved.
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
Before reporting an issue please consider:
|
||||
|
||||
* 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
|
||||
* If you want to request new functionality use the [**Feature Request issue** template](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=02-feature-request.yml)
|
||||
* If you want to request a new Source/Client implementation use the [**New Source/Client issue** template](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=03-new-source-client.yml)
|
||||
|
||||
If none of the above apply to you then create a new [**bug report**](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=01-bug-report.yml). Please include all [logs](#logs) and [play debug data](#copy-play-debug-data) relevant to your issue. The more logs the better. If possible, replicate the issue with [**debug mode** turned on](#debug-mode).
|
||||
+12
-5
@@ -10,9 +10,9 @@ sidebar_position: 1
|
||||
|
||||
<img src={require('/img/icon.png').default} align="right" alt="multi-scrobbler logo" width="180" height="180"/>
|
||||
|
||||
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), [ListenBrainz](https://listenbrainz.org), and [Koito](https://koito.io/).
|
||||
A dockerized app that monitors your music listening activity from *everywhere* and scrobbles it *anywhere*.
|
||||
|
||||
* Supports scrobbling from many [**Sources**](/configuration/sources)
|
||||
* Supports monitoring activity from many [**Sources**](/configuration/sources)
|
||||
* [Azuracast](/configuration/sources/azuracast)
|
||||
* [Deezer](/configuration/sources/deezer)
|
||||
* [Google Cast (Chromecast)](/configuration/sources/google-cast)
|
||||
@@ -22,6 +22,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Kodi](/configuration/sources/kodi)
|
||||
* [Koito](/configuration/sources/koito-source)
|
||||
* [Last.fm](/configuration/sources/lastfm-source)
|
||||
* [Libre.fm](/configuration/sources/librefm-source)
|
||||
* [Last.fm (Endpoint)](/configuration/sources/lastfm-endpoint)
|
||||
* [ListenBrainz](/configuration/sources/listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint)
|
||||
@@ -32,6 +33,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Musikcube](/configuration/sources/musikcube)
|
||||
* [Plex](/configuration/sources/plex)
|
||||
* [Rocksky](/configuration/sources/rocksky-source)
|
||||
* [Sonos](/configuration/sources/sonos)
|
||||
* [Spotify](/configuration/sources/spotify)
|
||||
* [Subsonic-compatible APIs](/configuration/sources/subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [teal.fm](/configuration/sources/tealfm-source)
|
||||
@@ -40,12 +42,15 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Yamaha MusicCast](/configuration/sources/yamaha-musiccast)
|
||||
* [Youtube Music](/configuration/sources/youtube-music)
|
||||
* Supports scrobbling to many [**Clients**](/configuration/clients)
|
||||
* [Discord](/configuration/clients/discord) (Now Playing)
|
||||
* [Koito](/configuration/clients/koito)
|
||||
* [Last.fm](/configuration/clients/lastfm)
|
||||
* [Libre.fm](/configuration/clients/librefm)
|
||||
* [ListenBrainz](/configuration/clients/listenbrainz)
|
||||
* [Maloja](/configuration/clients/maloja)
|
||||
* [Rocksky](/configuration/clients/rocksky)
|
||||
* [teal.fm](/configuration/clients/tealfm)
|
||||
* Enhance/correct scrobble data with [search patterns](/configuration/transforms), [built-in corrections](/configuration/transforms), or the [Musicbrainz](/configuration/transforms/musicbrainz) database
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](/configuration#webhook-configurations) or [healthcheck endpoint](/configuration#health-endpoint)
|
||||
* Supports [Now Playing](/configuration/clients#now-playing) for scrobble Clients
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
@@ -53,7 +58,6 @@ 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](/configuration#configuration-types)
|
||||
* Modify data before scrobbling with [regular expression or search patterns](/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](/installation#docker) or [locally with NodeJS](/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](/quickstart)
|
||||
@@ -66,18 +70,21 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* **Open-source** -- Get peace of mind knowing exactly how your personal data is being handled.
|
||||
* **Track your activity regardless of where you listen** -- Scrobble from many Sources to one Client with ease and without duplicating tracks.
|
||||
* **Manage scrobbling for others** -- Scrobble for your friends and family without any setup on their part. Easily silo sources to specific clients to keep plays separate.
|
||||
* **Standardize your data** -- Match your scrobbles with the [Musicbrainz](/configuration/transforms/musicbrainz) database to ensure scrobbled data is as correct and rich as possible.
|
||||
|
||||
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](/configuration/sources/lastfm-source) or [Listenbrainz as a **Source**](configuration/sources/listenbrainz-source) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
Use the **Last.fm** tab in the [Quickstart Sources](/quickstart/#setup-sources) to get started.
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
You set up [configurations](/configuration) for one or more [**Sources**](/configuration/sources) and one or more [**Clients**](/configuration/clients). MS monitors all of your configured **Sources**. When new tracks are played by a Source MS grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to forward to.
|
||||
|
||||
### Source
|
||||
|
||||
A [**Source**](/configuration/sources) is a data source that contains information about music you are playing or have listened to, like a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
|
||||
A [**Source**](/configuration/sources) is a data source that contains information about music you are playing or have listened to, such as: a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
|
||||
|
||||
Source configurations consist of:
|
||||
|
||||
@@ -87,7 +94,7 @@ Source configurations consist of:
|
||||
|
||||
### Client
|
||||
|
||||
A [**Client**](/configuration/clients) is an application that stores the historical information about what music you have played (scrobbles). Examples are **Maloja, Last.fm, Listenbrainz**...
|
||||
A [**Client**](/configuration/clients) is an application that stores the historical information about what music you have played (scrobbles). Examples are **Koito, Last.fm, Listenbrainz**...
|
||||
|
||||
Client configurations consist of:
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ You should bind a host directory into the container for storing configurations a
|
||||
docker run ... -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
<TabItem value="docker-compose" label="Docker Compose" default>
|
||||
[Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
@@ -94,7 +94,7 @@ If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-net
|
||||
docker run ... -p 9078:9078 foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
<TabItem value="docker-compose" label="Docker Compose" default>
|
||||
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
@@ -130,7 +130,7 @@ set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the
|
||||
docker run ... -e BASE_URL="http://hostMachineIP" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
<TabItem value="docker-compose" label="Docker Compose" default>
|
||||
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
@@ -162,6 +162,21 @@ To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
::::
|
||||
|
||||
#### Read Only
|
||||
|
||||
MS can be hardended by setting the file system as read only with [`read_only`](https://docs.docker.com/reference/compose-file/services/#read_only), with a few exceptions:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
# ...
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /run:exec
|
||||
- /tmp:noexec
|
||||
```
|
||||
|
||||
### Docker Usage Example
|
||||
|
||||
:::tip
|
||||
@@ -194,7 +209,7 @@ The example scenario:
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
<TabItem value="docker-compose" label="Docker Compose" default>
|
||||
See [`docker-compose.yml`](#docker) sample above for more options and annotations.
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
|
||||
+202
-48
@@ -10,8 +10,8 @@ This guide will get you up and running with multi-scrobbler using [Docker](https
|
||||
|
||||
* the dashboard served on port `9078` of a host machine which has an IP of `192.168.0.100`
|
||||
* data saved to the same directory as the `docker-compose.yml` file
|
||||
* multi-scrobbler monitoring [Spotify](/configuration/sources/spotify) and/or [Jellyfin](/configuration/sources/jellyfin) for listening activity
|
||||
* multi-scrobbler scrobbling to [Lastfm](/configuration/clients/lastfm) and/or [Maloja](/configuration/clients/maloja)
|
||||
* multi-scrobbler monitoring [Spotify](/configuration/sources/spotify), [Jellyfin](/configuration/sources/jellyfin), Navidrome, [Plex](/configuration/sources/plex), and/or [Last.fm](/configuration/sources/lastfm-source) for listening activity
|
||||
* multi-scrobbler scrobbling to [Lastfm](/configuration/clients/lastfm), [Maloja](/configuration/clients/maloja), and/or [Koito](/configuration/clients/koito)
|
||||
|
||||
:::note
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
|
||||
[**Sources**](/configuration/sources) are the services/music players multi-scrobbler monitors to look for listening activity.
|
||||
|
||||
<Tabs>
|
||||
<Tabs groupId="qsSource">
|
||||
<TabItem value="jellyfin" label="Jellyfin">
|
||||
Follow the Jellyfin configuration [instructions for setting up an **API Key**.](/configuration/sources/jellyfin)
|
||||
|
||||
@@ -72,13 +72,210 @@ services:
|
||||
|
||||
Later, after [starting multi-scrobbler](#start-multi-scrobbler), visit the dashboard at `http://192.168.0.100:9078` and click **(Re)authenticate** on the Spotify card to authorize multi-scrobbler to use your account. Monitoring will begin automatically after authorization is complete.
|
||||
</TabItem>
|
||||
<TabItem value="navidrome" label="Navidrome">
|
||||
Navidrome supports [scrobbling using Listenbrainz](https://www.navidrome.org/docs/usage/scrobbling/#listenbrainz). You should use this, rather than a [Subsonic Source](/configuration/sources/subsonic) because Navidrome's internal scrobbling functionality supports providing multiple artists and offline scrobbling while the Subsonic Source does not.
|
||||
|
||||
To setup Navidrome to scrobble to multi-scrobbler via the Listenbrainz option:
|
||||
|
||||
#### Create Listenbrainz Endpoint Source
|
||||
|
||||
Create a new [Listenbrainz Endpoint](/configuration/sources/listenbrainz-endpoint) [Source](/configuration/sources) in multi-scrobbler by creating a token to use with the endpoint via [ENV Config](/configuration?configType=env#configuration-types) in `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- LZE_TOKEN=myCoolToken
|
||||
```
|
||||
|
||||
The token can be anything. [(Re)start multi-scrobbler](#start-multi-scrobbler) after making this change.
|
||||
|
||||
#### Configure Navidrome
|
||||
|
||||
Set the [**ListenBrainz.BaseURL**](https://www.navidrome.org/docs/usage/configuration-options/#advanced-configuration) (env `ND_LISTENBRAINZ_BASEURL`) to `http://myMultiScrobblerIP:9078/1/` in your Navidrome installation. Assuming you are using the [docker installation](https://www.navidrome.org/docs/installation/docker/), modify `environment` to add:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
- ND_LISTENBRAINZ_BASEURL=http://192.168.0.100:9078/1/
|
||||
```
|
||||
|
||||
Restart Navidrome after making this change. Then, [follow the directions for enabling Listenbrainz scrobbling in Navidrome](https://www.navidrome.org/docs/usage/scrobbling/#listenbrainz). The **Listenbrainz user token** is the token you created for `LZE_TOKEN` earlier.
|
||||
</TabItem>
|
||||
<TabItem value="plex" label="Plex">
|
||||
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.
|
||||
|
||||
Add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- PLEX_URL=http://192.168.0.101:32400
|
||||
- PLEX_TOKEN=6c0ihz8j7__f5r18oa0zm8
|
||||
# optional, restrict scrobbling to a list of comma-delimited users
|
||||
#- PLEX_USERS_ALLOW=myPlexUsername
|
||||
```
|
||||
|
||||
[Restart multi-scrobbler](#start-multi-scrobbler) to create the new Source. You may wish to refer to the [User and Library defaults/filtering](/configuration/sources/plex) in the Plex Source docs for a more granular setup.
|
||||
</TabItem>
|
||||
<TabItem value="lastfm" label="Last.fm">
|
||||
If you are already scrobbling to Last.fm and wish to mirror your scrobbling activity to one or more other [Clients](#setup-clients), use this Source.
|
||||
|
||||
If, instead, you want to scrobble **from** multi-scrobbler **to** Last.fm then see the Last.fm tab in the [Setup Clients](#setup-clients) section below.
|
||||
|
||||
[Register for an API account at Last.fm.](https://www.last.fm/api/account/create)
|
||||
|
||||
Use the following for **Callback URL**:
|
||||
|
||||
```
|
||||
http://192.168.0.100:9078/lastfm/callback
|
||||
```
|
||||
|
||||
After account creation make note of the **Api Key** and **Secret** that are displayed.
|
||||
|
||||
Add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- SOURCE_LASTFM_API_KEY=MY_API_KEY_PLACEDHOLDER
|
||||
- SOURCE_LASTFM_SECRET=MY_SECRET_PLACEHOLDER
|
||||
- SOURCE_LASTFM_REDIRECT_URI=http://192.168.0.100:9078/lastfm/callback
|
||||
```
|
||||
|
||||
Later, after [starting multi-scrobbler](#start-multi-scrobbler), visit the dashboard at `http://192.168.0.100:9078` and click **(Re)authenticate** on the **Last.fm (Source)** card to authorize multi-scrobbler to use your account. Monitoring will begin automatically after authorization is complete.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## (Optional) Correct Scrobbles with Musicbrainz
|
||||
|
||||
Multi-scrobbler can use [Musicbrainz](https://musicbrainz.org/) to match activity from your Sources in order to [fill in missing data](/configuration/transforms/musicbrainz), or correct/normalize existing data, before it is scrobbled to the [Clients](#setup-clients) below. This step is optional.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Musicbrainz Setup Instructions</summary>
|
||||
|
||||
<h3>Configure Musicbrainz API</h3>
|
||||
|
||||
:::note
|
||||
|
||||
**This step only needs to be performed once** regardless of how many Sources you want to use Musicbrainz for.
|
||||
|
||||
:::
|
||||
|
||||
You must provide a way for Musicbrainz to [contact you.](https://wiki.musicbrainz.org/MusicBrainz_API/Rate_Limiting#How_can_I_be_a_good_citizen_and_be_smart_about_using_the_Web_Service?)
|
||||
|
||||
Add these value to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
# an email or personal website musicbrainz can use to contact you
|
||||
- MB_CONTACT=myCoolName@emailProvider.com
|
||||
# how musicbrainz searches and selects matches
|
||||
# isrc => then tries basic text fields => then tries extracted artists
|
||||
- MB_PRESETS=default,sensible,native
|
||||
```
|
||||
|
||||
**Presets** are found [here](/configuration/transforms/musicbrainz/#env-configuration).
|
||||
|
||||
<h3>Enable Musicbrainz For A Source</h3>
|
||||
|
||||
Musicbrainz can be enabled for multiple Sources. Follow the directions in each tab to enable it for that Source.
|
||||
|
||||
<Tabs groupId="qsSource">
|
||||
<TabItem value="jellyfin" label="Jellyfin">
|
||||
Add this value to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- JELLYFIN_TRANSFORMS=musicbrainz
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="spotify" label="Spotify">
|
||||
Add this value to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- SPOTIFY_TRANSFORMS=musicbrainz
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="navidrome" label="Navidrome">
|
||||
Add this value to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- LZE_TRANSFORMS=musicbrainz
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="plex" label="Plex">
|
||||
Add this value to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- PLEX_TRANSFORMS=musicbrainz
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="lastfm" label="Last.fm">
|
||||
Modify the `lastfm.json` file you created in the [Setup Sources](#setup-sources) step to add the highlighted section in the example below. The comma at the top highlighted section is important!
|
||||
|
||||
```json title="~/msData/config/lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFmSource",
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"apiKey": "MY_API_KEY_PLACEDHOLDER",
|
||||
"secret": "MY_SECRET_PLACEHOLDER",
|
||||
"redirectUri": "http://192.168.0.100:9078/lastfm/callback"
|
||||
// highlight-start
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"type": "musicbrainz"
|
||||
}
|
||||
}
|
||||
}
|
||||
// highlight-end
|
||||
}
|
||||
]
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
## Setup Clients
|
||||
|
||||
[**Clients**](/configuration/clients) are services that store scrobbles. Multi-scrobbler will scrobble all listening activity from the **Sources** you configured to all **Clients** you configure here.
|
||||
|
||||
<Tabs>
|
||||
<Tabs groupId="qsClient">
|
||||
<TabItem value="koito" label="Koito">
|
||||
Setup a [Koito server](https://koito.io/guides/installation/) if you have not already done this.
|
||||
|
||||
* [Create (or get) an API Key](https://koito.io/guides/scrobbler/)
|
||||
* From your Koito server dashboard navigate to **Settings** (bottom-left icon) => **API Keys**
|
||||
* Create a new Key (or use an existing) and Copy the key using the copy icon to the right of the key
|
||||
* EX `pM195xPV98CDpk0QW47FIIOR8AKATAX5DblBF-Jq0t1MbbKL`
|
||||
* Take note of your Koito username (used below as `myUser`)
|
||||
* Determine your Koito URL
|
||||
* This is the URL you use to access your Koito dashboard OR whatever URL is accessible from your multi-scrobbler instance.
|
||||
* EX dashboard at `http://192.168.0.100:4110` => replace `myURL` below with `http://192.168.0.100:4110`
|
||||
* [More info on the URL to use is the full docs](/configuration/clients/koito)
|
||||
|
||||
Add/Substitute values above into these examples values and add them 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"
|
||||
- KOITO_TOKEN=myToken
|
||||
- KOITO_USER=myUser
|
||||
- KOITO_URL=myURL
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito config set [`KOITO_ALLOWED_HOSTS`](https://koito.io/reference/configuration/#koito_allowed_hosts) to the IP the multi-scrobbler dashboard is accessible from.
|
||||
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="lastfm" label="Last.fm">
|
||||
[Register for an API account at Last.fm.](https://www.last.fm/api/account/create)
|
||||
|
||||
Use the following for **Callback URL**:
|
||||
|
||||
```
|
||||
http://192.168.0.100:9078/lastfm/callback
|
||||
```
|
||||
|
||||
After account creation use the displayed information and 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"
|
||||
- LASTFM_API_KEY=myApiKey
|
||||
- LASTFM_SECRET=myApiSecret
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="maloja" label="Maloja">
|
||||
Setup a [Maloja server](https://github.com/krateng/maloja?tab=readme-ov-file#how-to-install) if you have not already done this.
|
||||
|
||||
@@ -111,50 +308,6 @@ services:
|
||||
- MALOJA_API_KEY=myApiKey
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="lastfm" label="Last.fm">
|
||||
[Register for an API account at Last.fm.](https://www.last.fm/api/account/create)
|
||||
|
||||
Use the following for **Callback URL**:
|
||||
|
||||
```
|
||||
http://192.168.0.100:9078/lastfm/callback
|
||||
```
|
||||
|
||||
After account creation use the displayed information and 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"
|
||||
- LASTFM_API_KEY=myApiKey
|
||||
- LASTFM_SECRET=myApiSecret
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="koito" label="Koito">
|
||||
Setup a [Koito server](https://koito.io/guides/installation/) if you have not already done this.
|
||||
|
||||
* [Create (or get) an API Key](https://koito.io/guides/scrobbler/)
|
||||
* From your Koito server dashboard navigate to **Settings** (bottom-left icon) => **API Keys**
|
||||
* Create a new Key (or use an existing) and Copy the key using the copy icon to the right of the key
|
||||
* EX `pM195xPV98CDpk0QW47FIIOR8AKATAX5DblBF-Jq0t1MbbKL`
|
||||
* Take note of your Koito username (used below as `myUser`)
|
||||
* Determine your Koito URL
|
||||
* This is the URL you use to access your Koito dashboard OR whatever URL is accessible from your multi-scrobbler instance.
|
||||
* EX dashboard at `http://192.168.0.100:4110` => replace `myURL` below with `http://192.168.0.100:4110`
|
||||
* [More info on the URL to use is the full docs](/configuration/clients/koito)
|
||||
|
||||
Add/Substitute values above into these examples values and add them 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"
|
||||
- KOTIO_TOKEN=myToken
|
||||
- KOTIO_USER=myUser
|
||||
- KOITO_URL=myURL
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito config set [`KOITO_ALLOWED_HOSTS`](https://koito.io/reference/configuration/#koito_allowed_hosts) to the IP the multi-scrobbler dashboard is accessible from.
|
||||
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Start Multi-Scrobbler
|
||||
@@ -188,4 +341,5 @@ Visit `http://192.168.0.100:9078` to see the dashboard where
|
||||
* See all available [**Sources**](/configuration/sources) and [**Clients**](/configuration/clients) alongside configuration examples
|
||||
* Learn how to set up [notification webhooks](/configuration#webhook-configurations)
|
||||
* Check out the [kitchensink example](/configuration/kitchensink)
|
||||
* Setup [Scrobble Enhancements](/configuration/transforms) with [Musicbrainz](/configuration/transforms/musicbrainz) to automatically correct and fill in missing track information for your scrobbles
|
||||
* Consult the [**FAQ**](/FAQ) for solutions to common problems
|
||||
|
||||
@@ -71,7 +71,7 @@ const config: Config = {
|
||||
],
|
||||
],
|
||||
themes: [
|
||||
"docusaurus-json-schema-plugin",
|
||||
//"docusaurus-json-schema-plugin",
|
||||
[
|
||||
"@easyops-cn/docusaurus-search-local",
|
||||
/** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */
|
||||
@@ -90,6 +90,7 @@ const config: Config = {
|
||||
'docusaurus-theme-github-codeblock'
|
||||
],
|
||||
plugins: [
|
||||
'docusaurus-plugin-sass',
|
||||
[
|
||||
'@docusaurus/plugin-client-redirects',
|
||||
{
|
||||
@@ -160,7 +161,7 @@ const config: Config = {
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
|
||||
links: [
|
||||
{
|
||||
title: 'Docs',
|
||||
@@ -202,7 +203,7 @@ const config: Config = {
|
||||
prism: {
|
||||
theme: themes.themes.github,
|
||||
darkTheme: themes.themes.dracula,
|
||||
additionalLanguages: ['json','json5','typescript', 'docker', 'bash', 'ini']
|
||||
additionalLanguages: ['json','json5','typescript', 'docker', 'bash', 'ini', 'diff']
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: 'dark',
|
||||
|
||||
Generated
+1422
-102
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -11,7 +11,8 @@
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
"typecheck": "tsc",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.8.1",
|
||||
@@ -20,15 +21,22 @@
|
||||
"@docusaurus/preset-classic": "3.8.1",
|
||||
"@easyops-cn/docusaurus-search-local": "0.51.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"cf-json-schema-viz": "^0.3.0",
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-json-schema-plugin": "^1.14.0",
|
||||
"docusaurus-plugin-sass": "^0.2.6",
|
||||
"docusaurus-theme-github-codeblock": "^2.0.2",
|
||||
"json5": "^2.2.3",
|
||||
"micromark-extension-directive": "^3.0.1",
|
||||
"modern-react-json-editor": "^1.0.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"sass": "^1.97.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.6.3",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
||||
import Details from '@theme/Details';
|
||||
import type {Props as DetailProps} from '@theme/Details';
|
||||
import IconWarning from '@theme/Admonition/Icon/Warning';
|
||||
import IconDanger from '@theme/Admonition/Icon/Danger';
|
||||
import IconTip from '@theme/Admonition/Icon/Tip';
|
||||
import IconNote from '@theme/Admonition/Icon/Note';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
export interface DetailsAdmoProps extends DetailProps {
|
||||
type?: 'warning' | 'danger' | 'note' | 'tip'
|
||||
}
|
||||
|
||||
const DetailsAdmo = (props: DetailsAdmoProps) => {
|
||||
|
||||
let cn: string = '';
|
||||
let icon: ReactElement;
|
||||
|
||||
switch(props.type) {
|
||||
case 'warning':
|
||||
cn = 'alert--warning';
|
||||
icon = <IconWarning/>;
|
||||
break;
|
||||
case 'danger':
|
||||
cn = 'alert--danger';
|
||||
icon = <IconDanger/>;
|
||||
break;
|
||||
case 'note':
|
||||
cn = 'alert--secondary';
|
||||
icon = <IconNote/>;
|
||||
break;
|
||||
case 'tip':
|
||||
cn = 'alert--success';
|
||||
icon = <IconTip/>
|
||||
break;
|
||||
}
|
||||
|
||||
const iconWrapper = icon === undefined ? null : <span className="admoIcon">{icon}</span>;
|
||||
|
||||
const summary = <summary>{iconWrapper}{props.summary}</summary>;
|
||||
|
||||
return <Details className={cn} summary={summary}>
|
||||
{props.children}
|
||||
</Details>
|
||||
|
||||
};
|
||||
|
||||
export default DetailsAdmo;
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { ReactNode, CSSProperties } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Link from '@docusaurus/Link';
|
||||
|
||||
// Define the Button type to control the props that can be passed to the Button component.
|
||||
export type ButtonProps = {
|
||||
// The size prop can be one of the following values: 'sm', 'lg', 'small', 'medium', 'large', or null.
|
||||
// We'll convert 'small' to 'sm' and 'large' to 'lg' in the component. 'medium' will be considered null.
|
||||
size?: 'sm' | 'lg' | 'small' | 'medium' | 'large' | null;
|
||||
// The outline prop is a boolean that determines if the button should be an outline button.
|
||||
outline?: boolean;
|
||||
// The variant prop is a string that determines the color of the button.
|
||||
// It can be one of the following values: 'primary', 'secondary', 'danger', 'warning', 'success', 'info', 'link', or any other string.
|
||||
// The default value is 'primary'.
|
||||
variant: 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info' | 'link' | string;
|
||||
// The block prop is a boolean that determines if the button should be a block-level button.
|
||||
block?: boolean;
|
||||
// The disabled prop is a boolean that determines if the button should be disabled.
|
||||
disabled?: boolean;
|
||||
// The className prop is a string that allows you to add custom classes to the button.
|
||||
className?: string;
|
||||
// The style prop is an object that allows you to add custom styles to the button.
|
||||
style?: CSSProperties;
|
||||
// The link prop is a string that determines the URL the button should link to.
|
||||
link: string;
|
||||
// The label prop is a string that determines the text of the button.
|
||||
label: string;
|
||||
onClick?: () => any
|
||||
}
|
||||
|
||||
// Button component that accepts the specified props.
|
||||
export default function Button ({
|
||||
size = null,
|
||||
outline = false,
|
||||
variant = 'primary',
|
||||
block = false,
|
||||
disabled = false,
|
||||
className,
|
||||
style = {},
|
||||
link,
|
||||
label,
|
||||
onClick = () => null,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
// Map the size prop values to corresponding CSS classes.
|
||||
const sizeMap = {
|
||||
sm: 'sm',
|
||||
small: 'sm',
|
||||
lg: 'lg',
|
||||
large: 'lg',
|
||||
medium: null,
|
||||
};
|
||||
const buttonSize = size ? sizeMap[size] : '';
|
||||
const sizeClass = buttonSize ? `button--${buttonSize}` : '';
|
||||
const outlineClass = outline ? 'button--outline' : '';
|
||||
const variantClass = variant ? `button--${variant}` : '';
|
||||
const blockClass = block ? 'button--block' : '';
|
||||
const disabledClass = disabled ? 'disabled' : '';
|
||||
// If the button is disabled, set the destination to null.
|
||||
const destination = disabled ? null : link;
|
||||
return (
|
||||
<Link to={destination}>
|
||||
<button
|
||||
className={clsx(
|
||||
'button',
|
||||
sizeClass,
|
||||
outlineClass,
|
||||
variantClass,
|
||||
blockClass,
|
||||
disabledClass,
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
role='button'
|
||||
aria-disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick()
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { ReactNode, CSSProperties } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Link from '@docusaurus/Link';
|
||||
import Button, { ButtonProps } from './Button';
|
||||
|
||||
const leftButton = {
|
||||
borderRadius: 'var(--ifm-button-border-radius) 0 0 var(--ifm-button-border-radius)'
|
||||
}
|
||||
|
||||
const rightButton = {
|
||||
borderRadius: '0 var(--ifm-button-border-radius) var(--ifm-button-border-radius) 0'
|
||||
}
|
||||
|
||||
const middleButton = {
|
||||
borderRadius: '0'
|
||||
}
|
||||
|
||||
// Define the Button type to control the props that can be passed to the Button component.
|
||||
type ButtonGroup = {
|
||||
options: [string, string][]
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
// The size prop can be one of the following values: 'sm', 'lg', 'small', 'medium', 'large', or null.
|
||||
// We'll convert 'small' to 'sm' and 'large' to 'lg' in the component. 'medium' will be considered null.
|
||||
size?: 'sm' | 'lg' | 'small' | 'medium' | 'large' | null;
|
||||
|
||||
variant: 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info' | 'link' | string;
|
||||
// The block prop is a boolean that determines if the button should be a block-level button.
|
||||
block?: boolean;
|
||||
// The disabled prop is a boolean that determines if the button should be disabled.
|
||||
disabled?: boolean;
|
||||
// The className prop is a string that allows you to add custom classes to the button.
|
||||
className?: string;
|
||||
// The style prop is an object that allows you to add custom styles to the button.
|
||||
style?: CSSProperties;
|
||||
onChange?: (val: string) => any
|
||||
}
|
||||
|
||||
// Button component that accepts the specified props.
|
||||
export default function ButtonGroup ({
|
||||
size = null,
|
||||
variant = 'primary',
|
||||
block = false,
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
options,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange = () => null
|
||||
}: ButtonGroup) {
|
||||
|
||||
if(options === undefined || options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttons = options.map((opt, index) => {
|
||||
const props: ButtonProps = {
|
||||
style: middleButton,
|
||||
outline: true,
|
||||
size,
|
||||
variant,
|
||||
label: opt[1],
|
||||
link: '#',
|
||||
onClick: () => onChange(opt[0])
|
||||
};
|
||||
if(index === 0) {
|
||||
props.style = leftButton;
|
||||
} else if(index === options.length - 1) {
|
||||
props.style = rightButton;
|
||||
}
|
||||
|
||||
if((value ?? defaultValue) === opt[0]) {
|
||||
props.outline = false;
|
||||
}
|
||||
|
||||
return <Button key={index} {...props}/>
|
||||
|
||||
});
|
||||
|
||||
return <span>{buttons}</span>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { Fragment, useMemo } from "react"
|
||||
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
|
||||
import Admonition from '@theme/Admonition';
|
||||
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';
|
||||
import FileExample, { FileProps } from "./FileExample";
|
||||
|
||||
export interface FileConfigProps extends FileProps {
|
||||
data: string
|
||||
name: string
|
||||
configureAs?: 'client' | 'source'
|
||||
aio?: boolean
|
||||
filePathName?: string
|
||||
}
|
||||
|
||||
const FileConfigExample = (props: FileConfigProps) => {
|
||||
const {
|
||||
data,
|
||||
configureAs = 'source',
|
||||
aio = false,
|
||||
name,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
let configObj;
|
||||
// eslint-disable-next-line prefer-const
|
||||
try {
|
||||
configObj = json5.parse(data) as object[];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return <Admonition type="danger" title="Unexpected Error">
|
||||
<p>Example component crashed because of error!</p>
|
||||
<CodeBlock>{e.message}</CodeBlock>
|
||||
</Admonition>
|
||||
}
|
||||
|
||||
const transformed = useMemo(() => {
|
||||
let content: object;
|
||||
if(typeof data === 'string') {
|
||||
content = json5.parse(data);
|
||||
} else {
|
||||
content = data;
|
||||
}
|
||||
if(configureAs !== undefined && Array.isArray(content)) {
|
||||
const filtered = content.filter(x => x.configureAs === undefined || x.configureAs === configureAs);
|
||||
if(aio) {
|
||||
const configType = configureAs === 'client' ? 'clients' : 'sources';
|
||||
content = {[configType]: filtered.map(x => ({...x, type: name}))}
|
||||
} else {
|
||||
content = filtered;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
},[data, configureAs, aio, name])
|
||||
|
||||
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === configureAs);
|
||||
|
||||
return <FileExample data={transformed} {...rest}/>
|
||||
}
|
||||
|
||||
const WrappedFileConfigExample = (props: FileConfigProps) => {
|
||||
return <ErrorBoundary
|
||||
fallback={({error}) => (
|
||||
<div>
|
||||
<p>Example component crashed because of error: {error.message}.</p>
|
||||
</div>
|
||||
)}
|
||||
><FileConfigExample {...props} /></ErrorBoundary>
|
||||
}
|
||||
|
||||
export default WrappedFileConfigExample;
|
||||
@@ -1,44 +1,90 @@
|
||||
import React, { Fragment } from "react"
|
||||
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
|
||||
import React, { Fragment, useMemo } from "react"
|
||||
import CodeBlock, { Props as CodeBlockProps } from '@theme/CodeBlock';
|
||||
import Admonition from '@theme/Admonition';
|
||||
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';
|
||||
import { useTypedLocalStorage } from "./useLocalStorage";
|
||||
import ButtonGroup from "./ButtonGroup";
|
||||
import EditorFetch from '@site/src/components/Schema/SchemaEditorFetch';
|
||||
import {useWindowSize} from '@docusaurus/theme-common';
|
||||
|
||||
export interface FileProps extends Omit<CodeBlockProps, 'children'> {
|
||||
data: string
|
||||
client?: boolean
|
||||
data: string | object
|
||||
filePathName?: string
|
||||
schemaName?: string
|
||||
interactive?: boolean
|
||||
defaultDisplay?: 'plain' | 'interactive'
|
||||
readOnlyAtWidth?: number
|
||||
}
|
||||
|
||||
const FileExample = (props: FileProps) => {
|
||||
const {
|
||||
data,
|
||||
client = false,
|
||||
interactive = true,
|
||||
defaultDisplay = 'interactive',
|
||||
schemaName,
|
||||
filePathName,
|
||||
readOnlyAtWidth = 1,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
let configObj;
|
||||
// eslint-disable-next-line prefer-const
|
||||
try {
|
||||
configObj = json5.parse(data) as object[];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return <Admonition type="danger" title="Unexpected Error">
|
||||
<p>Example component crashed because of error!</p>
|
||||
<CodeBlock>{e.message}</CodeBlock>
|
||||
</Admonition>
|
||||
const [fileExampleShow, setFileExampleShow] = useTypedLocalStorage('docusaurus.tab.fileExampleShow', defaultDisplay, false);
|
||||
|
||||
const windowSize = useWindowSize({desktopBreakpoint: readOnlyAtWidth});
|
||||
|
||||
let displaySwitch = null;
|
||||
|
||||
const isInteractive = interactive && windowSize === 'desktop'
|
||||
|
||||
if (isInteractive) {
|
||||
displaySwitch = (
|
||||
<ButtonGroup
|
||||
options={[['plain', 'Plain'], ['interactive', 'Interactive']]}
|
||||
defaultValue={defaultDisplay}
|
||||
value={fileExampleShow}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
onChange={(val) => setFileExampleShow(val as 'interactive' | 'plain')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === (client ? 'client' : 'source'));
|
||||
let fileNameContent = null;
|
||||
if(filePathName !== undefined && (isInteractive && fileExampleShow === 'interactive')) {
|
||||
fileNameContent = <code style={{float: 'right', paddingRight: '0.5em', paddingLeft: '0.5em'}}>{filePathName}</code>
|
||||
}
|
||||
|
||||
return <CodeBlock {...rest} language="json5">{JSON.stringify(configObj, null, 2)}</CodeBlock>
|
||||
let header = null;
|
||||
if(displaySwitch !== null || fileNameContent !== null) {
|
||||
header = (
|
||||
<div style={{marginBottom: '1em'}}>
|
||||
{displaySwitch}{fileNameContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const codeBlockContent = useMemo(() =>{
|
||||
if(isInteractive && fileExampleShow === 'interactive') {
|
||||
return null;
|
||||
}
|
||||
if(typeof data !== 'string') {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
return data;
|
||||
}, [interactive, fileExampleShow, data]);
|
||||
|
||||
return (<Fragment>
|
||||
{header}
|
||||
{!isInteractive || fileExampleShow === 'plain' ? <CodeBlock title={filePathName} language="json5">{codeBlockContent}</CodeBlock> : null}
|
||||
{isInteractive && fileExampleShow === 'interactive' ? <EditorFetch configContent={data} schemaName={schemaName}/> : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const WrappedFileExample = (props: FileProps) => {
|
||||
return <ErrorBoundary
|
||||
fallback={({error}) => (
|
||||
fallback={({ error }) => (
|
||||
<div>
|
||||
<p>Example component crashed because of error: {error.message}.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import ExplorerFetch from '@site/src/components/Schema/ExplorerFetch';
|
||||
import EditorFetch from '@site/src/components/Schema/SchemaEditorFetch';
|
||||
import './GenericConfiguration.scss';
|
||||
import FileConfigExample from './FileConfigExample';
|
||||
import {useWindowSize} from '@docusaurus/theme-common';
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
@@ -10,19 +15,96 @@ import CodeBlock from '@theme/CodeBlock';
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<FileType>
|
||||
<div
|
||||
className="schemaWrapper"
|
||||
style={{
|
||||
display: "flex"
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '0 1em 0 1em', flexBasis: 'min-content', flexGrow: 2}}>
|
||||
<DetailsAdmo type="note" summary="Config Structure">
|
||||
This displays the **structure** of the [File Configuration](/configuration?configType=aio#configuration-types) for a <strong children={props.name}/> type <strong children={props.client ? 'Client' : 'Source'}/> with all possible properties, their shape, and descriptions/types. Use this to understand how to write a valid config.
|
||||
</DetailsAdmo>
|
||||
<ExplorerFetch name={`schemas/${props.config}`} />
|
||||
</div>
|
||||
<div style={{ flexBasis: 'auto', flexGrow: 3 }}>
|
||||
<div className="fileEditor">
|
||||
<DetailsAdmo type="note" summary="Config Example">
|
||||
This displays an **example config file** of a <strong children={props.name}/> <span children={props.client ? 'Client' : 'Source'}/> [File Configuration](/configuration?configType=aio#configuration-types) that adheres to the shown **Config Structure**.
|
||||
|
||||
<FileExample title={`CONFIG/${props.name}.json`} client={props.client ?? false} data={props.fileContent}/>
|
||||
* **text** mode lets you edit the JSON directly.
|
||||
* **tree** mode gives you a guided point-and-click editing experience that always keeps the JSON *syntax* valid.
|
||||
|
||||
or <SchemaLink lower objectName={props.config}/>
|
||||
Both modes **validate** that the configuraion is correct. Any errors show up as squiggly lines.
|
||||
|
||||
After you finish editing, switch to **text** and then copy all text to get a completed config.
|
||||
</DetailsAdmo>
|
||||
</div>
|
||||
<div className="fileEditorMobile">
|
||||
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
|
||||
This displays an **example config file** of a <strong children={props.name}/> <span children={props.client ? 'Client' : 'Source'}/> [File Configuration](/configuration?configType=aio#configuration-types) that adheres to the shown **Config Structure**.
|
||||
|
||||
The example config file editor is meant for a larger screen experience so **only the read-only example is shown.** Please use this site on a tablet/laptop/desktop to enable file editor features.
|
||||
</DetailsAdmo>
|
||||
</div>
|
||||
<FileConfigExample
|
||||
filePathName={`CONFIG/${props.name}.json`}
|
||||
configureAs={props.client ? 'client' : 'source'}
|
||||
data={props.fileContent}
|
||||
schemaName={props.config}
|
||||
readOnlyAtWidth={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FileType>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<AIOType>
|
||||
<div
|
||||
className="schemaWrapper"
|
||||
style={{
|
||||
display: "flex"
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '0 1em 0 1em', flexBasis: 'min-content', flexGrow: 2}}>
|
||||
<DetailsAdmo type="note" summary="Config Structure">
|
||||
This displays the **structure** of the [all-in-one (`config.json`) configuration](/configuration?configType=aio#configuration-types) with all possible properties, their shape, and descriptions/types. Use this to understand how to write a valid config.
|
||||
|
||||
<AIOExample data={props.fileContent} client={props.client ?? false} name={props.name}/>
|
||||
Select the <strong children={props.name}/> type from the respective <code children={props.client ? 'clients' : 'sources'}/> node below, then expand it.
|
||||
</DetailsAdmo>
|
||||
<ExplorerFetch name={`schemas/aio`} />
|
||||
</div>
|
||||
<div style={{ flexBasis: 'auto', flexGrow: 3 }}>
|
||||
<div className="fileEditor">
|
||||
<DetailsAdmo type="note" summary="Config Example">
|
||||
This displays an **example config file** of a [all-in-one (`config.json`) configuration](/configuration?configType=aio#configuration-types) that includes a <strong children={props.name}/> <span children={props.client ? 'Client' : 'Source'}/> that adheres to the shown **Config Structure**.
|
||||
|
||||
* **text** mode lets you edit the JSON directly.
|
||||
* **tree** mode gives you a guided point-and-click editing experience that always keeps the JSON *syntax* valid.
|
||||
|
||||
or <SchemaLink lower objectName={props.config}/>
|
||||
Both modes **validate** that the configuraion is correct. Any errors show up as squiggly lines.
|
||||
|
||||
After you finish editing, switch to **text** and then copy all text to get a completed config.
|
||||
</DetailsAdmo>
|
||||
</div>
|
||||
<div className="fileEditorMobile">
|
||||
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
|
||||
This displays an **example config file** of a [all-in-one (`config.json`) configuration](/configuration?configType=aio#configuration-types) that includes a <strong children={props.name}/> <span children={props.client ? 'Client' : 'Source'}/> that adheres to the shown **Config Structure**.
|
||||
|
||||
The example config file editor is meant for a larger screen experience so **only the read-only example is shown.** Please use this site on a tablet/laptop/desktop to enable file editor features.
|
||||
</DetailsAdmo>
|
||||
</div>
|
||||
<FileConfigExample
|
||||
filePathName={`CONFIG/config.json`}
|
||||
configureAs={props.client ? 'client' : 'source'}
|
||||
data={props.fileContent}
|
||||
schemaName="aio"
|
||||
name={props.name}
|
||||
readOnlyAtWidth={500}
|
||||
aio
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AIOType>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
@@ -0,0 +1,26 @@
|
||||
.schemaWrapper {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
row-gap: 1em;
|
||||
column-gap: 1em;
|
||||
}
|
||||
|
||||
.fileEditorMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1378px) {
|
||||
.schemaWrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.fileEditor {
|
||||
display: none;
|
||||
}
|
||||
.fileEditorMobile {
|
||||
display: block
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from "react"
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly"
|
||||
import { useColorMode } from "@docusaurus/theme-common"
|
||||
|
||||
import JSONSchemaEditor from "@theme/JSONSchemaEditor"
|
||||
// Context
|
||||
import { usePlaygroundContext } from "@site/src/contexts/PlaygroundContext"
|
||||
|
||||
// Common stringify of the JSON
|
||||
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t")
|
||||
|
||||
function JSONSchemaDataInner(): JSX.Element {
|
||||
const {
|
||||
state: { userSchema, editorRef, data: value },
|
||||
updateState,
|
||||
} = usePlaygroundContext()
|
||||
|
||||
const { colorMode } = useColorMode()
|
||||
|
||||
return (
|
||||
<div style={{ boxSizing: "border-box", width: "50%" }}>
|
||||
<JSONSchemaEditor
|
||||
value={value}
|
||||
schema={userSchema}
|
||||
theme={colorMode === "dark" ? "vs-dark" : "vs"}
|
||||
editorDidMount={(editor) => {
|
||||
updateState({ editorRef: editor })
|
||||
}}
|
||||
height={"70vh"}
|
||||
key={STRINGIFY_JSON(userSchema)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function JSONSchemaDataComponent(): JSX.Element {
|
||||
return (
|
||||
<BrowserOnly fallback={<div>Loading...</div>}>
|
||||
{() => {
|
||||
return <JSONSchemaDataInner />
|
||||
}}
|
||||
</BrowserOnly>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.schemaWrapper2 {
|
||||
margin: 1em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
row-gap: 1em;
|
||||
column-gap: 1em;
|
||||
}
|
||||
|
||||
.fileEditorMobile2 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1280px) {
|
||||
.schemaWrapper2 {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.fileEditor2 {
|
||||
display: none;
|
||||
}
|
||||
.fileEditorMobile2 {
|
||||
display: block
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,105 @@
|
||||
import React from "react"
|
||||
import React, {useEffect, useState} from "react"
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly"
|
||||
import {useColorMode, useWindowSize} from '@docusaurus/theme-common';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
|
||||
import Schema from "@site/static/aio.json";
|
||||
import Schema from "@site/static/schemas/aio.json";
|
||||
import ConfigExample from "@site/static/configExample.json";
|
||||
import JSONSchemaViewer from "@theme/JSONSchemaViewer"
|
||||
import JSONSchemaEditor from "@theme/JSONSchemaEditor";
|
||||
import {useColorMode} from "@docusaurus/theme-common";
|
||||
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t")
|
||||
import './Playground.scss';
|
||||
import { JsonSchemaViewer } from 'cf-json-schema-viz';
|
||||
import "./json-schema-viewer-styles.css";
|
||||
import "./modern-json-react-styles.css";
|
||||
import { ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
|
||||
import f from "ajv-formats"
|
||||
import DetailsAdmo from "./AdmonitionDetails";
|
||||
|
||||
const validator = createAjvValidator({
|
||||
schema: Schema,
|
||||
ajvOptions: {
|
||||
strict: "log",
|
||||
strictSchema: "log",
|
||||
allowUnionTypes: true,
|
||||
},
|
||||
onCreateAjv: (ajv) => {
|
||||
ajv.addKeyword('deprecationMessage');
|
||||
f.default(ajv);
|
||||
return ajv;
|
||||
}
|
||||
});
|
||||
|
||||
// based on https://github.com/jy95/docusaurus-json-schema-plugin/blob/main/testsite/src/components/PlaygroundInner.tsx
|
||||
function PlaygroundInner(): JSX.Element {
|
||||
|
||||
const { colorMode } = useColorMode()
|
||||
const {
|
||||
colorMode, // the "effective" color mode, never null
|
||||
// colorModeChoice, // the color mode chosen by the user, can be null
|
||||
// setColorMode, // set the color mode chosen by the user
|
||||
} = useColorMode();
|
||||
const windowSize = useWindowSize({desktopBreakpoint: 500});
|
||||
|
||||
const [data, setData] = useState<Content>({json: ConfigExample });
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflowY: "hidden",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{width: '50%'}}>
|
||||
<JSONSchemaViewer
|
||||
schema={Schema}
|
||||
showExamples={true}
|
||||
<div className="schemaWrapper2">
|
||||
<div style={{ padding: '0 1em 0 1em', flexBasis: 'min-content', flexGrow: 2}}>
|
||||
<DetailsAdmo type="note" summary="Config Structure">
|
||||
<p>This displays the <strong>structure</strong> of the <a href="/configuration?configType=aio#configuration-types">all-in-one (<code>config.json</code>) configuration</a> with all possible properties, their shape, and descriptions/types.</p>
|
||||
|
||||
<p>Use this to understand how to write a valid config.</p>
|
||||
</DetailsAdmo>
|
||||
<JsonSchemaViewer
|
||||
name="Todos Model"
|
||||
schema={Schema}
|
||||
expanded={false}
|
||||
hideTopBar={false}
|
||||
renderRootTreeLines={true}
|
||||
emptyText="No schema defined"
|
||||
defaultExpandedDepth={0}
|
||||
markup
|
||||
/>
|
||||
</div>
|
||||
<div style={{ boxSizing: "border-box", width: "50%" }}>
|
||||
<JSONSchemaEditor
|
||||
value={STRINGIFY_JSON(ConfigExample)}
|
||||
theme={colorMode === "dark" ? "vs-dark" : "vs"}
|
||||
schema={Schema}
|
||||
height={"70vh"}
|
||||
/>
|
||||
<div style={{ flexBasis: 'auto', flexGrow: 3 }}>
|
||||
<div className="fileEditor2">
|
||||
<DetailsAdmo type="note" summary="Config Example">
|
||||
<p>
|
||||
This displays an <strong>example config file</strong> of a <a href="/configuration?configType=aio#configuration-types">all-in-one (<code>config.json</code>) configuration</a> that adheres to the shown <strong>Config Structure.</strong>
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>text</strong> mode lets you edit the JSON directly.
|
||||
</li>
|
||||
<li>
|
||||
<strong>tree</strong> mode gives you a guided point-and-click editing experience that always keeps the JSON <i>syntax</i> valid.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>Both modes <strong>validate</strong> that the configuraion is correct. Any errors show up as squiggly lines.</p>
|
||||
|
||||
<p>After you finish editing, switch to <strong>text</strong> and then copy all text to get a completed config.</p>
|
||||
</DetailsAdmo>
|
||||
</div>
|
||||
<div className="fileEditorMobile2">
|
||||
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
|
||||
<p>
|
||||
This displays an <strong>example config file</strong> of a <a href="/configuration?configType=aio#configuration-types">all-in-one (<code>config.json</code>) configuration</a> that adheres to the shown <strong>Config Structure.</strong>
|
||||
</p>
|
||||
<p>
|
||||
The example config file editor is meant for a larger screen experience so <strong>only the read-only example is shown.</strong> Please use this site on a tablet/laptop/desktop to enable file editor features.
|
||||
</p>
|
||||
</DetailsAdmo>
|
||||
</div>
|
||||
{windowSize === 'mobile' ? (<CodeBlock title={`CONFIG/config.json`} language="json5">{JSON.stringify(data, null, 2)}</CodeBlock>) : (
|
||||
<ReactJsonEditor
|
||||
validator={validator}
|
||||
theme={colorMode}
|
||||
content={data}
|
||||
mode="text"
|
||||
style={{height: 'initial'}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, {useEffect, useState} from "react"
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly"
|
||||
|
||||
//import './Explorer.scss';
|
||||
import { JsonSchemaViewer } from 'cf-json-schema-viz';
|
||||
import '../json-schema-viewer-styles.css'
|
||||
|
||||
export interface ExplorerProps {
|
||||
schema: object
|
||||
}
|
||||
|
||||
// based on https://github.com/jy95/docusaurus-json-schema-plugin/blob/main/testsite/src/components/PlaygroundInner.tsx
|
||||
export default function Explorer(props: ExplorerProps): JSX.Element {
|
||||
|
||||
//injectStyles();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<JsonSchemaViewer
|
||||
schema={props.schema}
|
||||
renderRootTreeLines={true}
|
||||
emptyText="No schema defined"
|
||||
defaultExpandedDepth={0}
|
||||
markup
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// export default function PlaygroundComponent(): JSX.Element {
|
||||
// return (
|
||||
// <BrowserOnly fallback={<div>Loading...</div>}>
|
||||
// {() => {
|
||||
// return <PlaygroundInner />
|
||||
// }}
|
||||
// </BrowserOnly>
|
||||
// )
|
||||
// }
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, {useEffect, useState, Fragment} from "react"
|
||||
//import BrowserOnly from "@docusaurus/BrowserOnly"
|
||||
|
||||
import Explorer from './Explorer';
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
|
||||
export interface ExplorerFetchProps {
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function ExplorerFetchFunc(props: ExplorerFetchProps): JSX.Element {
|
||||
|
||||
const [schema, setSchema] = useState(undefined as undefined | Error | object);
|
||||
|
||||
const url = useBaseUrl(`/${props.name}.json`);
|
||||
|
||||
useEffect( () => {
|
||||
fetch(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => setSchema(data))
|
||||
.catch( (err) => setSchema(err) )
|
||||
}, [setSchema, url])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{schema === undefined && <div>Loading ...</div>}
|
||||
{schema !== undefined && schema instanceof Error && <div>Houston we have a problem : {schema.message}</div>}
|
||||
{schema !== undefined && !(schema instanceof Error) && <Explorer schema={schema}/>}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// export default function ExplorerFetch(props: ExplorerFetchProps): JSX.Element {
|
||||
// return (
|
||||
// <BrowserOnly fallback={<div>Loading...</div>}>
|
||||
// {() => {
|
||||
// return <ExplorerFetchFunc {...props} />
|
||||
// }}
|
||||
// </BrowserOnly>
|
||||
// )
|
||||
// }
|
||||
@@ -0,0 +1,7 @@
|
||||
import Schema from "@site/static/aio.json";
|
||||
import Explorer from './Explorer';
|
||||
|
||||
export default function ExplorerSSR(): JSX.Element {
|
||||
|
||||
return <Explorer schema={Schema}/>
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useState, useMemo } from "react"
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly"
|
||||
import { useColorMode } from '@docusaurus/theme-common';
|
||||
|
||||
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t");
|
||||
import "../modern-json-react-styles.css";
|
||||
import { JSONSchema, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
|
||||
import f from "ajv-formats"
|
||||
import json5 from 'json5';
|
||||
|
||||
export interface SchemaEditorProps {
|
||||
config: object | string
|
||||
schema?: object
|
||||
height?: string
|
||||
}
|
||||
|
||||
// based on https://github.com/jy95/docusaurus-json-schema-plugin/blob/main/testsite/src/components/PlaygroundInner.tsx
|
||||
function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
|
||||
|
||||
const validator: Validator | undefined = useMemo(() => {
|
||||
if(props.schema === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return createAjvValidator({
|
||||
schema: props.schema as JSONSchema,
|
||||
ajvOptions: {
|
||||
strict: "log",
|
||||
strictSchema: "log",
|
||||
allowUnionTypes: true,
|
||||
},
|
||||
onCreateAjv: (ajv) => {
|
||||
ajv.addKeyword('deprecationMessage');
|
||||
f.default(ajv);
|
||||
return ajv;
|
||||
}
|
||||
});
|
||||
}, [props.schema]);
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
let content: object;
|
||||
if(typeof props.config === 'string') {
|
||||
content = json5.parse(props.config);
|
||||
} else {
|
||||
content = props.config;
|
||||
}
|
||||
return {json: content};
|
||||
},[props.config])
|
||||
|
||||
|
||||
// const [data, setData] = useState({ json: props.config });
|
||||
|
||||
const {
|
||||
colorMode, // the "effective" color mode, never null
|
||||
} = useColorMode();
|
||||
|
||||
//const val = typeof props.config === 'object' ? STRINGIFY_JSON(props.config) : props.config;
|
||||
return (
|
||||
<ReactJsonEditor
|
||||
validator={validator}
|
||||
theme={colorMode}
|
||||
mode="text"
|
||||
content={initialData}
|
||||
style={{height: 'initial'}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SchemaEditor(props: SchemaEditorProps): JSX.Element {
|
||||
return (
|
||||
<BrowserOnly fallback={<div>Loading...</div>}>
|
||||
{() => {
|
||||
return <SchemaEditorInner {...props} />
|
||||
}}
|
||||
</BrowserOnly>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, {useEffect, useState, Fragment} from "react"
|
||||
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
import SchemaEditor from "./SchemaEditor";
|
||||
import json5 from 'json5';
|
||||
|
||||
export interface SchemaEditorFetchProps {
|
||||
configContent: string | object
|
||||
schemaName?: string
|
||||
}
|
||||
|
||||
export default function ExplorerFetchFunc(props: SchemaEditorFetchProps): JSX.Element {
|
||||
|
||||
const {
|
||||
schemaName
|
||||
} = props;
|
||||
|
||||
const [schema, setSchema] = useState(undefined as undefined | Error | object | false);
|
||||
const [config, setConfig] = useState(undefined as undefined | Error | object);
|
||||
|
||||
const schemaUrl = schemaName === undefined ? undefined : useBaseUrl(`schemas/${props.schemaName}.json`);
|
||||
//const configUrl = props.configName !== undefined ? useBaseUrl(`/${props.configName}.json`) : undefined;
|
||||
useEffect( () => {
|
||||
if(schemaUrl === undefined) {
|
||||
setSchema(false);
|
||||
} else {
|
||||
fetch(
|
||||
schemaUrl,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => setSchema(data))
|
||||
.catch( (err) => setSchema(err) )
|
||||
}
|
||||
}, [setSchema, schemaUrl])
|
||||
|
||||
// useEffect( () => {
|
||||
// if(configUrl !== undefined) {
|
||||
// fetch(
|
||||
// configUrl,
|
||||
// {
|
||||
// headers: {
|
||||
// 'Accept': 'application/json',
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// .then((response) => response.json())
|
||||
// .then((data) => setConfig(data))
|
||||
// .catch( (err) => setConfig(err) )
|
||||
// } else if(props.configContent !== undefined) {
|
||||
// if(typeof props.configContent === 'string') {
|
||||
// setConfig(json5.parse(props.configContent));
|
||||
// } else {
|
||||
// setConfig(props.configContent);
|
||||
// }
|
||||
// }
|
||||
// }, [setConfig, configUrl, props.configContent])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{schema === undefined && <div>Loading ...</div>}
|
||||
{schema !== undefined && schema instanceof Error && <div>Houston we have a problem : {schema.message}</div>}
|
||||
{schema !== undefined && !(schema instanceof Error) &&
|
||||
<SchemaEditor
|
||||
schema={schema === false ? undefined : schema}
|
||||
config={props.configContent}
|
||||
/>}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/* JSON Schema Viewer - Pure CSS with CSS Custom Properties */
|
||||
|
||||
/* ========================================
|
||||
Theme Variables - Light Mode (default)
|
||||
Define at :root so portaled elements inherit them
|
||||
======================================== */
|
||||
:root {
|
||||
--jsv-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--jsv-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
--jsv-font-size: 0.875rem;
|
||||
|
||||
/* Colors - Light Mode defaults */
|
||||
--jsv-color-body: #1a1a1a;
|
||||
--jsv-color-muted: #6b7280;
|
||||
--jsv-color-border: #e5e7eb;
|
||||
--jsv-color-canvas: #f4f4f5;
|
||||
--jsv-color-canvas-tint: #f4f4f5;
|
||||
--jsv-color-bg: #ffffff;
|
||||
|
||||
/* Semantic colors - Cloudflare orange as primary */
|
||||
--jsv-color-primary: #f6821f;
|
||||
--jsv-color-danger: #dc2626;
|
||||
--jsv-color-warning: #f97316;
|
||||
--jsv-color-success: #16a34a;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Theme Variables - Dark Mode
|
||||
Cloudflare docs dark theme colors
|
||||
======================================== */
|
||||
:root[data-theme="dark"],
|
||||
[data-theme="dark"] {
|
||||
--jsv-color-body: #e4e4e7;
|
||||
--jsv-color-muted: #a1a1aa;
|
||||
--jsv-color-border: #3f3f46;
|
||||
--jsv-color-canvas: #27272a;
|
||||
--jsv-color-canvas-tint: #3f3f46;
|
||||
--jsv-color-bg: #18181b;
|
||||
|
||||
--jsv-color-primary: #f6821f;
|
||||
--jsv-color-danger: #f87171;
|
||||
--jsv-color-warning: #fb923c;
|
||||
--jsv-color-success: #4ade80;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Base Styles
|
||||
======================================== */
|
||||
.jsv-root {
|
||||
font-size: var(--jsv-font-size);
|
||||
font-family: var(--jsv-font-sans);
|
||||
color: var(--jsv-color-body);
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.jsv-content {
|
||||
padding-left: 2rem;
|
||||
padding-right: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: 0.375rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Schema Row
|
||||
======================================== */
|
||||
.jsv-row {
|
||||
position: relative;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.jsv-row-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jsv-row-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Nubbin - horizontal connector from left border to property */
|
||||
.jsv-nubbin {
|
||||
width: 0.75rem;
|
||||
height: 0;
|
||||
border-top: 1px solid var(--jsv-color-border);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
margin-left: -1rem;
|
||||
}
|
||||
|
||||
.jsv-nubbin-short {
|
||||
width: 0.25rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Child Stack (nested content with left border)
|
||||
======================================== */
|
||||
.jsv-child-stack {
|
||||
font-size: var(--jsv-font-size);
|
||||
}
|
||||
|
||||
.jsv-child-stack-nested {
|
||||
border-left: 1px solid var(--jsv-color-border);
|
||||
margin-left: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Property Name
|
||||
======================================== */
|
||||
.jsv-property-name {
|
||||
font-family: var(--jsv-font-mono);
|
||||
font-weight: 600;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Type Display
|
||||
======================================== */
|
||||
.jsv-type {
|
||||
color: var(--jsv-color-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jsv-types-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
font-size: var(--jsv-font-size);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Badges (required, deprecated, read-only)
|
||||
======================================== */
|
||||
.jsv-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jsv-badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.jsv-badge-required {
|
||||
color: var(--jsv-color-warning);
|
||||
}
|
||||
|
||||
.jsv-badge-deprecated {
|
||||
color: var(--jsv-color-warning);
|
||||
}
|
||||
|
||||
.jsv-badge-readonly {
|
||||
color: var(--jsv-color-muted);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Divider (horizontal line on hover)
|
||||
======================================== */
|
||||
.jsv-divider {
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
min-width: 1rem;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.jsv-divider-visible {
|
||||
background-color: var(--jsv-color-border);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Description
|
||||
======================================== */
|
||||
.jsv-description {
|
||||
font-size: var(--jsv-font-size);
|
||||
color: var(--jsv-color-muted);
|
||||
margin-top: 0.0625rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Validations
|
||||
======================================== */
|
||||
.jsv-validations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.jsv-validations-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.jsv-validation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
color: var(--jsv-color-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.jsv-validation-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jsv-validation-values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jsv-validation-value {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background-color: var(--jsv-color-canvas-tint);
|
||||
color: var(--jsv-color-muted);
|
||||
border: 1px solid var(--jsv-color-border);
|
||||
border-radius: 0.25rem;
|
||||
font-family: var(--jsv-font-mono);
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Caret (expand/collapse icon)
|
||||
======================================== */
|
||||
.jsv-caret {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-left: -1.5rem;
|
||||
margin-right: 0.25rem;
|
||||
color: var(--jsv-color-muted);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.jsv-caret:hover {
|
||||
color: var(--jsv-color-body);
|
||||
}
|
||||
|
||||
.jsv-caret-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Error Display
|
||||
======================================== */
|
||||
.jsv-error {
|
||||
color: var(--jsv-color-danger);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Change Annotations (added/removed/modified)
|
||||
======================================== */
|
||||
.jsv-annotation {
|
||||
position: absolute;
|
||||
width: 0.25rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.125rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.jsv-annotation-added {
|
||||
background-color: var(--jsv-color-success);
|
||||
}
|
||||
|
||||
.jsv-annotation-removed {
|
||||
background-color: var(--jsv-color-danger);
|
||||
}
|
||||
|
||||
.jsv-annotation-modified {
|
||||
background-color: var(--jsv-color-warning);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Path Crumbs (sticky breadcrumb header)
|
||||
Wrapper is sticky with height:0 (no layout impact)
|
||||
Inner crumbs div is absolute, overlays content
|
||||
======================================== */
|
||||
.jsv-crumbs {
|
||||
height: 2rem;
|
||||
padding-left: 2rem;
|
||||
padding-right: 1rem;
|
||||
font-family: var(--jsv-font-mono);
|
||||
font-size: var(--jsv-font-size);
|
||||
line-height: 1;
|
||||
z-index: 20;
|
||||
background-color: var(--sl-color-bg, var(--jsv-color-bg, #fff));
|
||||
color: var(--jsv-color-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--jsv-color-border);
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-top-right-radius: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
/* Hidden by default */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsv-crumbs,
|
||||
:root[data-theme="dark"] .jsv-crumbs {
|
||||
border-bottom-color: var(--sl-color-gray-5, #52525b);
|
||||
}
|
||||
|
||||
.jsv-crumbs[data-visible="true"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.jsv-crumb-current {
|
||||
color: var(--jsv-color-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Select / Menu Dropdown
|
||||
======================================== */
|
||||
.jsv-select-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: var(--jsv-font-size);
|
||||
font-family: var(--jsv-font-sans);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--jsv-color-border);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--jsv-color-muted);
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.jsv-select-trigger:hover {
|
||||
background-color: var(--jsv-color-canvas);
|
||||
border-color: var(--jsv-color-muted);
|
||||
}
|
||||
|
||||
.jsv-select-trigger:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--jsv-color-primary);
|
||||
}
|
||||
|
||||
.jsv-select-positioner {
|
||||
z-index: 9999 !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.jsv-select-popup {
|
||||
background-color: var(--jsv-color-bg);
|
||||
border: 1px solid var(--jsv-color-border);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.25rem 0;
|
||||
min-width: 10rem;
|
||||
overflow: hidden;
|
||||
font-family: var(--jsv-font-sans);
|
||||
font-size: var(--jsv-font-size);
|
||||
}
|
||||
|
||||
.jsv-select-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-left: 2rem;
|
||||
font-size: var(--jsv-font-size);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
outline: none;
|
||||
color: var(--jsv-color-body);
|
||||
background-color: var(--jsv-color-bg);
|
||||
position: relative;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.jsv-select-item:hover,
|
||||
.jsv-select-item:focus,
|
||||
.jsv-select-item[data-highlighted] {
|
||||
background-color: var(--jsv-color-canvas);
|
||||
}
|
||||
|
||||
.jsv-select-item[data-selected] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jsv-select-item-indicator {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--jsv-color-primary);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Top Level Styles
|
||||
======================================== */
|
||||
.jsv-top-label {
|
||||
font-family: var(--jsv-font-mono);
|
||||
font-weight: 600;
|
||||
font-size: var(--jsv-font-size);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.jsv-top-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.jsv-combiner-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--jsv-color-muted);
|
||||
font-size: var(--jsv-font-size);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Link Styles
|
||||
======================================== */
|
||||
.jsv-link {
|
||||
color: var(--jsv-color-primary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.jsv-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Misc Helpers
|
||||
======================================== */
|
||||
.jsv-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jsv-ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.jse-theme-dark{--jse-theme: dark;--jse-theme-color: #2f6dd0;--jse-theme-color-highlight: #467cd2;--jse-background-color: #1e1e1e;--jse-text-color: #d4d4d4;--jse-text-color-inverse: #4d4d4d;--jse-main-border: 1px solid #4f4f4f;--jse-menu-color: #fff;--jse-modal-background: #2f2f2f;--jse-modal-overlay-background: rgba(0, 0, 0, .5);--jse-modal-code-background: #2f2f2f;--jse-tooltip-color: var(--jse-text-color);--jse-tooltip-background: #4b4b4b;--jse-tooltip-border: 1px solid #737373;--jse-tooltip-action-button-color: inherit;--jse-tooltip-action-button-background: #737373;--jse-panel-background: #333333;--jse-panel-background-border: 1px solid #464646;--jse-panel-color: var(--jse-text-color);--jse-panel-color-readonly: #737373;--jse-panel-border: 1px solid #3c3c3c;--jse-panel-button-color-highlight: #e5e5e5;--jse-panel-button-background-highlight: #464646;--jse-navigation-bar-background: #656565;--jse-navigation-bar-background-highlight: #7e7e7e;--jse-navigation-bar-dropdown-color: var(--jse-text-color);--jse-context-menu-background: #4b4b4b;--jse-context-menu-background-highlight: #595959;--jse-context-menu-separator-color: #595959;--jse-context-menu-color: var(--jse-text-color);--jse-context-menu-pointer-background: #737373;--jse-context-menu-pointer-background-highlight: #818181;--jse-context-menu-pointer-color: var(--jse-context-menu-color);--jse-key-color: #9cdcfe;--jse-value-color: var(--jse-text-color);--jse-value-color-number: #b5cea8;--jse-value-color-boolean: #569cd6;--jse-value-color-null: #569cd6;--jse-value-color-string: #ce9178;--jse-value-color-url: #ce9178;--jse-delimiter-color: #949494;--jse-edit-outline: 2px solid var(--jse-text-color);--jse-selection-background-color: #464646;--jse-selection-background-inactive-color: #333333;--jse-hover-background-color: #343434;--jse-active-line-background-color: rgba(255, 255, 255, .06);--jse-search-match-background-color: #343434;--jse-collapsed-items-background-color: #333333;--jse-collapsed-items-selected-background-color: #565656;--jse-collapsed-items-link-color: #b2b2b2;--jse-collapsed-items-link-color-highlight: #ec8477;--jse-search-match-color: #724c27;--jse-search-match-outline: 1px solid #966535;--jse-search-match-active-color: #9f6c39;--jse-search-match-active-outline: 1px solid #bb7f43;--jse-tag-background: #444444;--jse-tag-color: #bdbdbd;--jse-table-header-background: #333333;--jse-table-header-background-highlight: #424242;--jse-table-row-odd-background: rgba(255, 255, 255, .1);--jse-input-background: #3d3d3d;--jse-input-border: var(--jse-main-border);--jse-button-background: #808080;--jse-button-background-highlight: #7a7a7a;--jse-button-color: #e0e0e0;--jse-button-secondary-background: #494949;--jse-button-secondary-background-highlight: #5d5d5d;--jse-button-secondary-background-disabled: #9d9d9d;--jse-button-secondary-color: var(--jse-text-color);--jse-a-color: #55abff;--jse-a-color-highlight: #4387c9;--jse-svelte-select-background: #3d3d3d;--jse-svelte-select-border: 1px solid #4f4f4f;--list-background: #3d3d3d;--item-hover-bg: #505050;--multi-item-bg: #5b5b5b;--input-color: #d4d4d4;--multi-clear-bg: #8a8a8a;--multi-item-clear-icon-color: #d4d4d4;--multi-item-outline: 1px solid #696969;--list-shadow: 0 2px 8px 0 rgba(0, 0, 0, .4);--jse-color-picker-background: #656565;--jse-color-picker-border-box-shadow: #8c8c8c 0 0 0 1px}
|
||||
@@ -1,2 +1 @@
|
||||
[**AIO** Config Type](/configuration?configType=aio#configuration-types)
|
||||
{props.children}
|
||||
@@ -0,0 +1,10 @@
|
||||
<DetailsAdmo type="warning" summary="Insecure Communication">
|
||||
|
||||
This method of exposing Discord IPC is **not secure**. There is no authentication used for the traffic relayed to/from the opened TCP port.
|
||||
|
||||
You should only use this method if you trust the network your machine is on. Additionally:
|
||||
|
||||
* **Do** use a firewall to limit source (IP) of incoming traffic to this TCP port
|
||||
* **Do not** expose this port to the public internet
|
||||
|
||||
</DetailsAdmo>
|
||||
@@ -1,3 +1,5 @@
|
||||
[**ENV** Config Type](/configuration?configType=env#configuration-types)
|
||||
:::note[Configuration Type]
|
||||
This is configuration for the **ENV** [Config Type](/configuration?configType=env#configuration-types).
|
||||
:::
|
||||
|
||||
{props.children}
|
||||
@@ -1,3 +1 @@
|
||||
[**File** Config Type](/configuration?configType=file#configuration-types)
|
||||
|
||||
{props.children}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ShouldScrobble from "@site/src/components/snippets/_should-scrobble.mdx"
|
||||
|
||||
<DetailsAdmo type="note" summary="Should Scrobble Behavior">
|
||||
<ShouldScrobble {...props}/>
|
||||
</DetailsAdmo>
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Inspired by typings from https://github.com/michal-worwag/hooks-ts/blob/main/packages/hooks-ts/src/useLocalStorage/useLocalStorage.ts
|
||||
* and global updates/dedup from https://github.com/kaansey/react-localstorage-hook/blob/master/src/useLocalStorage/useLocalStorage.ts
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
|
||||
const localTypedStorageHook = <T>(key: string, initialValue: T, json: boolean = true): [T, (value: T) => void] => {
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (json) {
|
||||
try {
|
||||
return item ? (JSON.parse(item) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return initialValue;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (null === item) {
|
||||
return initialValue;
|
||||
}
|
||||
if (item === 'true') {
|
||||
return true as T;
|
||||
}
|
||||
if (item === 'false') {
|
||||
return false as T;
|
||||
}
|
||||
if (isNumeric(item)) {
|
||||
if (item.includes('.')) {
|
||||
return parseFloat(item) as T;
|
||||
}
|
||||
return parseInt(item) as T;
|
||||
}
|
||||
return item as T;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return initialValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const updateValue = useCallback(
|
||||
newValue => {
|
||||
setStoredValue(() => {
|
||||
if(json) {
|
||||
localStorage.setItem(key, JSON.stringify(newValue))
|
||||
} else {
|
||||
localStorage.setItem(key, newValue)
|
||||
}
|
||||
return newValue
|
||||
})
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: any) => {
|
||||
if (event.storageArea === localStorage && event.key === key) {
|
||||
setStoredValue(
|
||||
event.newValue === null ? initialValue : event.newValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', onStorage)
|
||||
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
});
|
||||
|
||||
return [storedValue, updateValue];
|
||||
|
||||
}
|
||||
|
||||
const isNumeric = (num: any) => (typeof(num) === 'number' || typeof(num) === "string" && num.trim() !== '') && !isNaN(num as number);
|
||||
|
||||
const createTypedLocalStorageHook = <T>(key: string, defaultValue: T, json?: boolean) => {
|
||||
const updates: Array<any> = []
|
||||
|
||||
return () => {
|
||||
const [value, setValue] = localTypedStorageHook<T>(key, defaultValue, json)
|
||||
const updateValue = useCallback((newValue: T) => {
|
||||
for (const update of updates) {
|
||||
update(newValue)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
updates.push(setValue)
|
||||
return () => {
|
||||
updates.splice(updates.indexOf(setValue), 1)
|
||||
}
|
||||
}, [setValue])
|
||||
|
||||
return [value, updateValue]
|
||||
}
|
||||
}
|
||||
|
||||
const hookInstances: Record<string, any> = {}
|
||||
|
||||
export const useTypedLocalStorage = <T>(key: string, defaultValue: T, json?: boolean): [T, (value: T) => void] => {
|
||||
if (key in hookInstances) {
|
||||
return hookInstances[key]()
|
||||
}
|
||||
|
||||
// prevents usage during server static rendering
|
||||
if(!ExecutionEnvironment.canUseDOM) {
|
||||
return [defaultValue, () => null];
|
||||
}
|
||||
|
||||
hookInstances[key] = createTypedLocalStorageHook(key, defaultValue, json)
|
||||
return hookInstances[key]()
|
||||
}
|
||||
@@ -28,3 +28,66 @@
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* https://github.com/facebook/docusaurus/discussions/5749 */
|
||||
details[class^='details_'].alert--info {
|
||||
--ifm-alert-border-color: var(--ifm-color-emphasis-400);
|
||||
/* --ifm-alert-background-color: transparent; */
|
||||
--ifm-code-background: #{palette.$background};
|
||||
--ifm-alert-padding-vertical: calc(var(--ifm-spacing-vertical) * 0.8);
|
||||
|
||||
margin-bottom: var(--ifm-paragraph-margin-bottom);
|
||||
|
||||
&:not(.alert--warning,.alert--success,.alert--secondary,.alert--danger) {
|
||||
--ifm-alert-background-color: transparent;
|
||||
|
||||
> summary {
|
||||
min-height: 1.3rem;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
> summary::before {
|
||||
top: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.alert--success {
|
||||
--ifm-alert-background-color: var(--ifm-color-success-contrast-background);
|
||||
--ifm-alert-foreground-color: var(--ifm-color-info-contrast-foreground);
|
||||
}
|
||||
&.alert--secondary {
|
||||
--ifm-alert-background-color: var(--ifm-color-secondary-contrast-background);
|
||||
--ifm-alert-foreground-color: var(--ifm-color-secondary-contrast-foreground);
|
||||
}
|
||||
|
||||
> summary {
|
||||
--docusaurus-details-decoration-color: currentColor;
|
||||
font: var(--ifm-heading-font-weight) var(--ifm-h5-font-size) / var(--ifm-heading-line-height) var(--ifm-heading-font-family);
|
||||
|
||||
&::before {
|
||||
top: 0.40rem;
|
||||
}
|
||||
|
||||
.admoIcon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.4em;
|
||||
|
||||
> svg {
|
||||
display: inline-block;
|
||||
height: 1.6em;
|
||||
width: 1.6em;
|
||||
fill: var(--ifm-alert-foreground-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
> div > div[class^='collapsibleContent_'] {
|
||||
margin-top: var(--ifm-alert-padding-vertical);
|
||||
}
|
||||
|
||||
|
||||
dl {
|
||||
--ifm-spacing-vertical: 0.5rem;
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user