mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
86a719ba4a | ||
|
|
39b6f6e602 | ||
|
|
bfce5dcc54 | ||
|
|
18537e4e08 | ||
|
|
30db8712fc | ||
|
|
1701a5e5f1 | ||
|
|
406c39b6b2 | ||
|
|
ce7ad36db4 | ||
|
|
fa4bf5ab10 | ||
|
|
83d9af2aea | ||
|
|
433761525c | ||
|
|
f7a7a36d8b | ||
|
|
7e375f1531 | ||
|
|
357397f225 | ||
|
|
78bf507226 | ||
|
|
f892851b60 | ||
|
|
4244ddb8c2 | ||
|
|
3e67e9189f | ||
|
|
de1cbb2aae | ||
|
|
db17455b2e | ||
|
|
a7729fbfa6 | ||
|
|
771fdc0686 | ||
|
|
8cad2d4efd | ||
|
|
a094a1aa65 | ||
|
|
f2d45c5622 | ||
|
|
a429bccf07 | ||
|
|
04d950980d | ||
|
|
7ec90b97d6 | ||
|
|
a4f989c5e4 | ||
|
|
da4fbd2f27 | ||
|
|
b9e6f32500 | ||
|
|
5c5c653c3b | ||
|
|
a9e7a4bf16 | ||
|
|
1b4baafa67 | ||
|
|
794ef08aa2 | ||
|
|
fa6ed323ff | ||
|
|
8110e8e9fa | ||
|
|
12a6eec740 | ||
|
|
472021dd2c | ||
|
|
ebb74d1b5c | ||
|
|
ea23a89101 | ||
|
|
9062619d38 | ||
|
|
3fb64be54f | ||
|
|
3e083b19f7 | ||
|
|
e197c71bbb | ||
|
|
7b36d95dfc | ||
|
|
0427b6e945 | ||
|
|
326d9e58d7 | ||
|
|
63c079f08e | ||
|
|
ff74fd7e4c | ||
|
|
680f862ee8 | ||
|
|
abdd7f0b55 | ||
|
|
c017dd734b | ||
|
|
ad478adefd | ||
|
|
b11bd52963 | ||
|
|
538df6b6d0 | ||
|
|
819afcdb8a | ||
|
|
a5693f7c03 | ||
|
|
22eb5b1168 | ||
|
|
9402caa4ae | ||
|
|
0aff56e891 | ||
|
|
a5f6aaa7c0 | ||
|
|
4ee503655c | ||
|
|
e53a82782c | ||
|
|
020c6815ba | ||
|
|
5281a92e81 | ||
|
|
8577eb44af | ||
|
|
edd3cf25b5 | ||
|
|
b17719c868 | ||
|
|
971ec9d4db | ||
|
|
6607654e8a | ||
|
|
891e591ceb | ||
|
|
2540ed3945 | ||
|
|
7ac3f33cd4 | ||
|
|
9b91f6897e | ||
|
|
bc62b99401 | ||
|
|
a5d1cea910 | ||
|
|
3e3473f6a8 | ||
|
|
a0bdd15d15 | ||
|
|
920eb10c05 | ||
|
|
ae7a436758 | ||
|
|
92d246b45a | ||
|
|
0af480ac1a | ||
|
|
9f52c2ec67 | ||
|
|
cfb278087e | ||
|
|
402b0d2cc7 | ||
|
|
67c79f2eb7 | ||
|
|
d759b00c7e | ||
|
|
cac8163e58 | ||
|
|
11dbdaf6cc | ||
|
|
ac6b04f492 | ||
|
|
0790da2e0d | ||
|
|
dffb1c79ec | ||
|
|
46cc9f0b6e | ||
|
|
140e9f0b20 | ||
|
|
0fe5779c4b | ||
|
|
06b1475b63 | ||
|
|
6bc415120d | ||
|
|
7cee9f04cf |
@@ -26,7 +26,9 @@
|
||||
"extensions": [
|
||||
"hbenl.vscode-mocha-test-adapter",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"unifiedjs.vscode-mdx"
|
||||
"unifiedjs.vscode-mdx",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"TakumiI.markdowntable"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,8 +12,10 @@ jobs:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
@@ -41,7 +43,7 @@ jobs:
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 #3.9.3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: dataaxiom/ghcr-cleanup-action@v1
|
||||
- uses: dataaxiom/ghcr-cleanup-action@cd0cdb900b5dbf3a6f2cc869f0dbb0b8211f50c4 #1.0.16
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
+29
-16
@@ -48,10 +48,13 @@ jobs:
|
||||
# list all registries to push to and join all non-empty with comma
|
||||
# https://unix.stackexchange.com/a/693165/116849
|
||||
# https://stackoverflow.com/a/9429887/1469797
|
||||
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
|
||||
strings=("${VARS_DOCKERHUB_SLUG}" "${VARS_GHCR_SLUG}")
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
env:
|
||||
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
|
||||
VARS_GHCR_SLUG: ${{vars.GHCR_SLUG}}
|
||||
|
||||
# do not use here!
|
||||
# instead we can specify repo#sha in build-push-action so we only deal with foreign SHA for actually building image
|
||||
@@ -73,14 +76,14 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -89,13 +92,13 @@ jobs:
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 #v3.11.1
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
|
||||
with:
|
||||
# specify foreign repo#sha from PR that is used to build image
|
||||
context: ${{github.event.pull_request.head.repo.clone_url}}#${{github.event.pull_request.head.sha}}
|
||||
@@ -114,7 +117,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 #4.6.2
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -133,7 +136,7 @@ jobs:
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #4.3.0
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -141,25 +144,25 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 #5.9.0
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
@@ -180,24 +183,34 @@ jobs:
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
$(printf "${VARS_DOCKERHUB_SLUG}@sha256:%s " *)
|
||||
env:
|
||||
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
|
||||
|
||||
- name: Create manifest list and push gchr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
|
||||
$(printf "${VARS_GHCR_SLUG}@sha256:%s " *)
|
||||
env:
|
||||
VARS_GHCR_SLUG: ${{ vars.GHCR_SLUG }}
|
||||
|
||||
- name: Inspect image dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${VARS_DOCKERHUB_SLUG}:${STEPS_META_OUTPUTS_VERSION}
|
||||
env:
|
||||
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
|
||||
STEPS_META_OUTPUTS_VERSION: ${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Inspect image ghcr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${VARS_GHCR_SLUG}:${STEPS_META_OUTPUTS_VERSION}
|
||||
env:
|
||||
VARS_GHCR_SLUG: ${{ vars.GHCR_SLUG }}
|
||||
STEPS_META_OUTPUTS_VERSION: ${{ steps.meta.outputs.version }}
|
||||
|
||||
|
||||
combine-and-comment:
|
||||
@@ -207,7 +220,7 @@ jobs:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
steps:
|
||||
- name: Create comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 #2.9.4
|
||||
with:
|
||||
recreate: true
|
||||
header: "pr-release"
|
||||
|
||||
@@ -62,13 +62,19 @@ jobs:
|
||||
# list all registries to push to and join all non-empty with comma
|
||||
# https://unix.stackexchange.com/a/693165/116849
|
||||
# https://stackoverflow.com/a/9429887/1469797
|
||||
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
|
||||
strings=("${VARS_DOCKERHUB_SLUG}" "${VARS_GHCR_SLUG}")
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
env:
|
||||
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
|
||||
VARS_GHCR_SLUG: ${{vars.GHCR_SLUG}}
|
||||
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: appvars
|
||||
@@ -94,14 +100,14 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -110,13 +116,13 @@ jobs:
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 #v3.11.1
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
|
||||
with:
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
@@ -129,11 +135,12 @@ jobs:
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
digest="${VARS_DIGEST}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
env:
|
||||
VARS_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 #4.6.2
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -152,7 +159,7 @@ jobs:
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #4.3.0
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -160,25 +167,25 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 #5.9.0
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
@@ -208,21 +215,31 @@ jobs:
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
$(printf "${VARS_DOCKERHUB_SLUG}@sha256:%s " *)
|
||||
env:
|
||||
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
|
||||
|
||||
- name: Create manifest list and push gchr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
|
||||
$(printf "${VARS_GHCR_SLUG}@sha256:%s " *)
|
||||
env:
|
||||
VARS_GHCR_SLUG: ${{ vars.GHCR_SLUG }}
|
||||
|
||||
- name: Inspect image dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${VARS_DOCKERHUB_SLUG}:${STEPS_META_OUTPUTS_VERSION}
|
||||
env:
|
||||
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
|
||||
STEPS_META_OUTPUTS_VERSION: ${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Inspect image ghcr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${VARS_GHCR_SLUG}:${STEPS_META_OUTPUTS_VERSION}
|
||||
env:
|
||||
VARS_GHCR_SLUG: ${{ vars.GHCR_SLUG }}
|
||||
STEPS_META_OUTPUTS_VERSION: ${{ steps.meta.outputs.version }}
|
||||
@@ -19,11 +19,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #4.4.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
@@ -122,6 +122,7 @@ config/mscache
|
||||
config/yti-*
|
||||
config/*.cache
|
||||
*.txt
|
||||
!robots.txt
|
||||
.idea/
|
||||
|
||||
src/**/**.js
|
||||
|
||||
Vendored
+12
-1
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"mochaExplorer.require": "tsx/esm",
|
||||
"mochaExplorer.timeout": 1200000,
|
||||
"mochaExplorer.exit": true
|
||||
"mochaExplorer.exit": true,
|
||||
"search.exclude": {
|
||||
"**/.docusaurus": true,
|
||||
"**/build": true
|
||||
},
|
||||
"tailwindCSS.files.exclude": [
|
||||
"docsite/**"
|
||||
],
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"tailwindCSS.experimental.configFile": "src/client/index.css"
|
||||
}
|
||||
@@ -9,48 +9,53 @@
|
||||
<img src="/assets/icon.png" 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), and [ListenBrainz](https://listenbrainz.org)
|
||||
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.
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
|
||||
* [Deezer](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
|
||||
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
|
||||
* [Icecast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#icecast)
|
||||
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jellyfin)
|
||||
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
|
||||
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#koito-source)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja-source)
|
||||
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
|
||||
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
|
||||
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
|
||||
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [~~Tautulli~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
|
||||
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
|
||||
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
|
||||
* [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc)
|
||||
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
|
||||
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#koito)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
|
||||
* Supports scrobbling from many [**Sources**](https://foxxmd.github.io/multi-scrobbler/configuration/sources)
|
||||
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/azuracast)
|
||||
* [Deezer](https://foxxmd.github.io/multi-scrobbler/configuration/sources/deezer)
|
||||
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/google-cast)
|
||||
* [Icecast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/icecast)
|
||||
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/configuration/sources/jellyfin)
|
||||
* [JRiver](https://foxxmd.github.io/multi-scrobbler/configuration/sources/jriver)
|
||||
* [Kodi](https://foxxmd.github.io/multi-scrobbler/configuration/sources/kodi)
|
||||
* [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)
|
||||
* [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)
|
||||
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mopidy)
|
||||
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mpd)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mpris)
|
||||
* [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)
|
||||
* [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)
|
||||
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/configuration/sources/webscrobbler)
|
||||
* [VLC](https://foxxmd.github.io/multi-scrobbler/configuration/sources/vlc)
|
||||
* [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)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/configuration/clients/koito)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/lastfm)
|
||||
* [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)
|
||||
* 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
|
||||
* Graceful network and client failure handling (queued scrobbles that auto-retry)
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker) or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
* Easy configuration through [ENVs or JSON](ttps://foxxmd.github.io/multi-scrobbler/configuration#configuration-types)
|
||||
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/installation#docker) or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler//quickstart)
|
||||
|
||||
<img src="/assets/status-ui.png" width="800">
|
||||
|
||||
@@ -63,15 +68,15 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source) or [Listenbrainz as a **Source**](https://foxxmd.github.io/multi-scrobbler/docs/configuration#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.
|
||||
Yes! You can use [Last.fm as a **Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-source) or [Listenbrainz as a **Source**](https://foxxmd.github.io/multi-scrobbler/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.
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
You set up configurations for one or more **Sources** and one or more **Clients**. MS monitors all of your configured **Sources**. When new tracks are played by a Source it grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to scrobble to.
|
||||
You set up [configurations](https://foxxmd.github.io/multi-scrobbler/configuration) for one or more [**Sources**](https://foxxmd.github.io/multi-scrobbler/configuration/sources) and one or more [**Clients**](https://foxxmd.github.io/multi-scrobbler/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** is a data source that contains information about tracks you are playing like a music player or platform. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Airsonic**, 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, like a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
|
||||
|
||||
Source configurations consist of:
|
||||
|
||||
@@ -81,7 +86,7 @@ Source configurations consist of:
|
||||
|
||||
### Client
|
||||
|
||||
A **Client** is an application that stores the historical information about what songs 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 **Maloja, Last.fm, Listenbrainz**...
|
||||
|
||||
Client configurations consist of:
|
||||
|
||||
@@ -90,15 +95,15 @@ Client configurations consist of:
|
||||
|
||||
## Quick Start
|
||||
|
||||
[See the **Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
[See the **Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/quickstart)
|
||||
|
||||
## Installation
|
||||
|
||||
[See the **Installation** documentation](https://foxxmd.github.io/multi-scrobbler/docs/installation)
|
||||
[See the **Installation** documentation](https://foxxmd.github.io/multi-scrobbler/installation)
|
||||
|
||||
## Configuration
|
||||
|
||||
[See the **Configuration** documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration)
|
||||
[See the **Configuration** documentation](https://foxxmd.github.io/multi-scrobbler/configuration)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -113,11 +118,11 @@ On first startup you may need to authorize Spotify and/or Last.fm by visiting th
|
||||
|
||||
## Help/FAQ
|
||||
|
||||
Having issues with connections or configuration? Check the [FAQ](https://foxxmd.github.io/multi-scrobbler/docs/FAQ) before creating an issue!
|
||||
Having issues with connections or configuration? Check the [FAQ](https://foxxmd.github.io/multi-scrobbler/FAQ) before creating an issue!
|
||||
|
||||
## Development
|
||||
|
||||
[Detailed architecture and development guides for Sources/Clients](https://foxxmd.github.io/multi-scrobbler/docs/development/dev-common)
|
||||
[Detailed architecture and development guides for Sources/Clients](https://foxxmd.github.io/multi-scrobbler/development/dev-common)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
"username": "FoxxMD",
|
||||
"url": "https://api.listenbrainz.org"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -14,7 +15,8 @@
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
"username": "FoxxMD",
|
||||
"url": "https://api.listenbrainz.org"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true,
|
||||
"logFilterFailure": "debug"
|
||||
"logFilterFailure": "debug",
|
||||
"ignoreInvalidCert": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "myRockskyClient",
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"key": "7cdr86vis3mpq5b1pi01md0hkm7ykzzxjy81r",
|
||||
"handle": "alice.bsky.social"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "myRockskySource",
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"key": "e4xwktm7jxm607x8el1fcda30eu14dzb64h3j",
|
||||
"handle": "alice.bsky.social"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "myTeal",
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"identifier": "alice.bsky.social",
|
||||
"appPassword": "twog-phu7-4dhe-y4j3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "myTealSource",
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"identifier": "alice.bsky.social",
|
||||
"appPassword": "twog-phu7-4dhe-y4j3"
|
||||
}
|
||||
}
|
||||
]
|
||||
+8
-8
@@ -33,7 +33,7 @@ First, turn on **debug** logging for multi-scrobbler by setting the environmenta
|
||||
|
||||
##### Check Host name and URL
|
||||
|
||||
The URLs examples in the [configuration](configuration/configuration.mdx) documentation assume you are running Webscrobbler/LFM Endpoint on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
The URLs examples in the [configuration](/configuration#base-url) documentation assume you are running Webscrobbler/LFM Endpoint on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
|
||||
EX `http://localhost:9078/api/webscrobbler` -> `http://192.168.0.140:9078/api/webscrobbler`
|
||||
|
||||
@@ -70,7 +70,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/configuration.mdx?ytmAuth=ytt#youtube-music) If you completed authentication by entering a "User Code" you are using YoutubeTV which has stopped working. You should reauthenticate using **Cookies** or **Custom OAuth.**
|
||||
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.**
|
||||
|
||||
#### When using OAuth Client Authentication
|
||||
|
||||
@@ -80,7 +80,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/configuration.mdx?ytmAuth=cookie#youtube-music) 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#authentication) and then restart MS to potentially resolve the problem.
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
@@ -108,7 +108,7 @@ Multi-scrobbler works the same was the official Spotify-Last.fm integration work
|
||||
|
||||
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
|
||||
|
||||
To diagnose these issues you [**must enable payload logging**](configuration/configuration.mdx#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
|
||||
To diagnose these issues you [**must enable payload logging**](/configuration/sources/google-cast#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
|
||||
|
||||
### Google Cast device does not track media
|
||||
|
||||
@@ -120,17 +120,17 @@ MS logs will tell you what type the media is reported as with lines like:
|
||||
My Artist - Example Track has 'unknown' media type and allowUnknownMedia=false, will not track
|
||||
```
|
||||
|
||||
Refer to [Allow Unknown Media Type](configuration/configuration.mdx#allow-unknown-media-type) section to fix this
|
||||
Refer to [Allow Unknown Media Type](/configuration/sources/google-cast#allow-unknown-media-type) section to fix this
|
||||
|
||||
```
|
||||
My Artist - Example Track has 'movie' media type so will not track
|
||||
```
|
||||
|
||||
Refer to [Force Media Tracking](configuration/configuration.mdx#forcing-media-tracking) section to fix this
|
||||
Refer to [Force Media Tracking](/configuration/sources/google-cast#forcing-media-tracking) section to fix this
|
||||
|
||||
### VLC is not scrobbling fields correctly
|
||||
|
||||
Before reporting an issue turn on metadata logging in the MS VLC configuration, [see the VLC documentation.](configuration/configuration.mdx#vlc-information-reporting)
|
||||
Before reporting an issue turn on metadata logging in the MS VLC configuration, [see the VLC documentation.](/configuration/sources/vlc#vlc-information-reporting)
|
||||
|
||||
### Youtube Music misses or duplicates scrobbles
|
||||
|
||||
@@ -169,7 +169,7 @@ In your YTM configuration (`ytmusic.json`) add `logDiff` under `options` like th
|
||||
or set either ENVs:
|
||||
|
||||
* `YTM_LOG_DIFF=true`
|
||||
* [`DEBUG_MODE=true`](configuration/configuration.mdx?#debug-mode)
|
||||
* [`DEBUG_MODE=true`](/configuration#debug-mode)
|
||||
|
||||
This will cause MS to log YTM history changes similar to this:
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Configuration",
|
||||
"position": 4,
|
||||
"position": 5,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Configuring Multi-Scrobbler and examples"
|
||||
"type": "doc",
|
||||
"id": "configuration/configuration"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"label": "Scrobble Clients",
|
||||
"link": {
|
||||
"type": "doc",
|
||||
"id": "configuration/clients/clients"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Overview
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
import ScrobbleThreshold from "@site/src/components/snippets/_scrobble-threshold.mdx"
|
||||
|
||||
A **Client** is an application that stores the historical information about what songs you have played (scrobbles). Examples are **Maloja, Last.fm, Listenbrainz**...
|
||||
|
||||
## Clients
|
||||
|
||||
| Name | Now Playing |
|
||||
| :-------------------------------------------------- | :---------- |
|
||||
| [Last.fm](/configuration/clients/lastfm) | ✅ |
|
||||
| [Listenbrainz](/configuration/clients/listenbrainz) | ✅ |
|
||||
| [Koito](/configuration/clients/koito) | ❌ |
|
||||
| [Maloja](/configuration/clients/maloja) | ❌ |
|
||||
| [Rocksky](/configuration/clients/rocksky) | ❌ |
|
||||
| [Teal.fm](/configuration/clients/tealfm) | ❌ |
|
||||
|
||||
## Features
|
||||
|
||||
### Dead Scrobbles
|
||||
|
||||
If multi-scrobbler is unable to submit a scrobble to a Client then it places the scrobble into a queue which is retried every 5 minutes for a number of times before it gives up.
|
||||
|
||||
After it stops _automatically_ retrying the scrobble still exists and can be retried from the dashboard. Dead scrobbles persist between restart so you will never lose a scrobble that hasn't been successfully submitted yet.
|
||||
|
||||
### Now Playing
|
||||
|
||||
Multi-scrobbler can report the currently **playing** tracks it is monitoring to some Scrobble Clients via their individual **Now Playing** functionality.
|
||||
|
||||
Now Playing is **default enabled** for all Clients that support it.
|
||||
|
||||
The behavior multi-scrobbler uses for determining Now Playing reporting:
|
||||
|
||||
* Now Playing can be explicitly enabled or disabled globally using ENV `NOW_PLAYING=true` or `NOW_PLAYING=false`
|
||||
* This only affects Clients that don't have behavior set via File/AIO (below)
|
||||
* Now Playing will be only be reported to the same Clients a Source is configured to scrobble to
|
||||
* A Source's Player status must be **Playing**, IE **active**, for reporting to occur
|
||||
* This status can be verified via Player UI on the Dashboard
|
||||
* If multiple Sources having active Players then the scrobble Client will default to reporting the track based on Source **configuration** name, alphabetically
|
||||
* This is the `name` property set in [File or AIO source configs](/configuration?configType=file#configuration-types)
|
||||
|
||||
Clients can customize the Now Playing behavior individually using [File or AIO source configs:](/configuration?configType=file#configuration-types)
|
||||
|
||||
```json5 title="CONFIG_DIR/lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFmClient",
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
// disable or enable
|
||||
//"nowPlaying": true
|
||||
//
|
||||
// OR define a list of Source *config* names that should be allowed to report Now Playing
|
||||
// order of the list determines priority for reporting Now Playing
|
||||
//"nowPlaying": ["mySpotify1","myJellyfin2"]
|
||||
}
|
||||
},
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Koito
|
||||
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/koito.json.example';
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
To monitor and re-scrobble activity from a Koito account create a [Koito (Source)](/configuration/sources/koito-source)
|
||||
|
||||
:::
|
||||
|
||||
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 `KOITO_USER`)
|
||||
* 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` => `KOITO_URL` is set to `http://192.168.0.100:4110`
|
||||
|
||||
:::note[Koito Base URL]
|
||||
|
||||
Using the URL path `/apis/listenbrainz` [Koito docs describe](https://koito.io/guides/scrobbler/) **is not required.** The Koito URL used for multi-scrobbler should only be the **base** URL. Only include a URL path if your Koito **base** url is not at `host:port`. See the table below for examples.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Base URL Examples</summary>
|
||||
| `KOITO_URL` | MS Detected Base URL | Example Scrobble Api URL |
|
||||
| :-------------------------------------------- | :------------------------------ | ---------------------------------------------------- |
|
||||
| `http://192.168.0.100:4110` | `http://192.168.0.100:4110` | `http://192.168.0.100:4110/apis/listenbrainz/1` |
|
||||
| `https://koito.mydomain.com` | `https://koito.mydomain.com` | `https://koito.mydomain.com/apis/listenbrainz/1` |
|
||||
| `http://192.168.0.100:4110/apis/listenbrainz` | `http://192.168.0.100:4110` | `http://192.168.0.100:4110/apis/listenbrainz/1` |
|
||||
| `http://192.168.0.100:80/koito` | `http://192.168.0.100:80/koito` | `http://192.168.0.100:80/koito/apis/listenbrainz/1` |
|
||||
</details>
|
||||
:::
|
||||
|
||||
|
||||
:::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.
|
||||
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
<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 |
|
||||
</Config>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Last.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/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 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)
|
||||
|
||||
The Callback URL is actually specified by multi-scrobbler but to keep things consistent you should use
|
||||
```
|
||||
http://localhost:9078/lastfm/callback
|
||||
```
|
||||
or replace `localhost:9078` with your own base URL.
|
||||
|
||||
## 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 |
|
||||
| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
|
||||
</Config>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Listenbrainz
|
||||
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/listenbrainz.json.example';
|
||||
|
||||
:::tip[Other Users]
|
||||
|
||||
* To monitor and re-scrobble activity from a Listenbrainz account create a [Listenbrainz (Source)](/configuration/sources/listenbrainz-source)
|
||||
* To accept scrobbles from outside applications as if multi-scrobbler was a Listenbrainz server, create a [Listenbrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint)
|
||||
|
||||
:::
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
|
||||
## 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>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Maloja
|
||||
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/maloja.json.example';
|
||||
|
||||
Setup a [Maloja server](https://github.com/krateng/maloja?tab=readme-ov-file#how-to-install) if you have not already done this.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Maloja Setup Intructions</summary>
|
||||
|
||||
Using Maloja's example `docker-compose.yml`:
|
||||
|
||||
```yaml reference title="~/malojaData/docker-compose.yml"
|
||||
https://github.com/krateng/maloja/blob/master/example-compose.yml
|
||||
```
|
||||
|
||||
Uncomment `environment` and add `MALOJA_FORCE_PASSWORD=CHANGE_ME` to set an admin password
|
||||
|
||||
Start the container:
|
||||
|
||||
```shell title="~/malojaData"
|
||||
docker compose up -d
|
||||
```
|
||||
</details>
|
||||
|
||||
* Navigate to the Admin Panel (Cog in upper-right corner) -> API Keys (or at http://myMalojaServerIP/admin_apikeys)
|
||||
* Create a **New Key** and then copy the generated key value
|
||||
|
||||
Finally, add the Maloja server URL and API Key to the configuration type you choose to use, below.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MalojaClientConfig" fileContent={JsonConfig} client name="maloja">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|-------------------------------|
|
||||
| `MALOJA_URL` | Yes | | Base URL of your installation |
|
||||
| `MALOJA_API_KEY` | Yes | | Api Key |
|
||||
</Config>
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: Rocksky
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Bluesky from "@site/src/components/snippets/_bluesky.mdx";
|
||||
import JsonConfig from '!!raw-loader!@site/../config/rocksky.json.example';
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
To monitor and re-scrobble activity **from** Rocksky create a [Rocksky (Source)](/configuration/sources/rocksky-source)
|
||||
|
||||
:::
|
||||
|
||||
[**Rocksky**](https://rocksky.app/) is music tracking and discovery platform built on the AT Protocol (Bluesky).
|
||||
|
||||
<details>
|
||||
|
||||
<summary>How does that work?</summary>
|
||||
|
||||
<Bluesky name="Rocksky" lexicon="app.rocksky"/>
|
||||
|
||||
</details>
|
||||
|
||||
:::warning[Public Data]
|
||||
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
:::warning[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).
|
||||
|
||||
:::
|
||||
|
||||
## Setup
|
||||
|
||||
You will need a [Bluesky](https://bsky.app/) account to use Rocksky.
|
||||
|
||||
:::note
|
||||
|
||||
Usage is adapted from [Rocksky docs](https://docs.rocksky.app/migrating-to-rocksky-scrobble-api-957839m0) on [docs.rocksky.app](https://docs.rocksky.app)
|
||||
|
||||
:::
|
||||
|
||||
* Navigate to [rocksky.app](https://rocksky.app/) and sign up/logn using your Bluesky account
|
||||
* Then, navigate to the [RockSky developer dashboard](https://rocksky.app/apikeys) and obtain an API Key
|
||||
|
||||
## Configuration
|
||||
|
||||
### Handle
|
||||
|
||||
The handle used with multi-scrobbler should be your **full** ATProto handle, including TLD.
|
||||
|
||||
* 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
|
||||
|
||||
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">
|
||||
| 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 |
|
||||
</Config>
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: Teal.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/tealfm.json.example';
|
||||
import Bluesky from "@site/src/components/snippets/_bluesky.mdx";
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
To monitor and re-scrobble activity from a ATProto Profile create a [teal.fm (Source)](/configuration/sources/tealfm-source)
|
||||
|
||||
:::
|
||||
|
||||
[**teal.fm**](https://teal.fm/) is a social discovery and web viewing service for scrobbles stored/available on the ATProto network/your repository in a [PDS (Personal Data Server)](https://github.com/Bluesky-social/pds).
|
||||
|
||||
<details>
|
||||
|
||||
<summary>How does that work?</summary>
|
||||
|
||||
<Bluesky name="teal.fm" lexicon="fm.teal"/>
|
||||
|
||||
</details>
|
||||
|
||||
:::warning
|
||||
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
[teal.fm is still in developement](https://notes.teal.fm/3lzlwe6puis2l) and does not have a public website, yet.
|
||||
|
||||
To view your teal.fm scrobbles you can:
|
||||
|
||||
* use [wrapped.baileytownsend.dev](https://wrapped.baileytownsend.dev/) which gives a simple stats overview of your scrobbles
|
||||
* use [teal-slice.wisp.place](https://tealfm-slice.wisp.place/) for a preview of teal.fm
|
||||
* use [atproto.at](https://atproto.at) to view the raw data (convenience URLs are generated in multi-scrobbler for you to do this)
|
||||
|
||||
:::
|
||||
|
||||
## Setup
|
||||
|
||||
#### Bluesky
|
||||
|
||||
* Create a [Bluesky](https://bsky.app) account, if you don't already have one
|
||||
* Navigate to https://bsky.app/settings/app-passwords
|
||||
* Settings -> Privacy and Security -> App passwords
|
||||
* Create a new **App Password** and save it somewhere, it will not be shown again
|
||||
|
||||
## Configuration
|
||||
|
||||
### Identifier
|
||||
|
||||
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 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 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 or DID. For Bluesky account this is like `myUser.bsky.social` |
|
||||
| TEALFM_APP_PW | Yes | | Bluesky/ATProto network App Password |
|
||||
</Config>
|
||||
File diff suppressed because it is too large
Load Diff
+28
-9
@@ -3,21 +3,22 @@ sidebar_position: 3
|
||||
title: Kitchen Sink
|
||||
---
|
||||
|
||||
# Example Config using all Possible Features
|
||||
# Example Complex Scenario
|
||||
|
||||
Scenario:
|
||||
|
||||
* You want to scrobble plays for yourself (Foxx), Fred, and Mary
|
||||
* Each person has their own Maloja server
|
||||
* Each person has their own [Maloja](/configuration/clients/maloja) server
|
||||
* Each person has their own Spotify account
|
||||
* You have your own Airsonic (subsonic) server you to scrobble from
|
||||
* You have your own Youtube Music account you want to scrobble from
|
||||
* Mary has her own Last.fm account she also wants to scrobble to
|
||||
* Fred has his own Spotify application and provides you with just his access and refresh token because he doesn't trust you (wtf Fred)
|
||||
* 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
|
||||
* 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 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 and wants to scrobble everything he plays
|
||||
* 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
|
||||
* You have an android music app that can scrobble to a [custom listenbrainz server](/configuration/sources/listenbrainz-endpoint)
|
||||
|
||||
### All-in-one Config
|
||||
|
||||
@@ -124,6 +125,14 @@ Scenario:
|
||||
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "endpointlz",
|
||||
"name": "listenbrainzfoxx",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"token": "myToken"
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
@@ -246,7 +255,6 @@ Scenario:
|
||||
```json5 title="CONFIG_DIR/ytmusic.json"
|
||||
[
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "foxxYoutube",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
@@ -255,6 +263,17 @@ Scenario:
|
||||
}
|
||||
]
|
||||
```
|
||||
```json5 title="CONFIG_DIR/endpointlz.json"
|
||||
[
|
||||
{
|
||||
"name": "listenbrainzfoxx",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"token": "myToken",
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```json5 title="CONFIG_DIR/maloja.json"
|
||||
[
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Plex/Tautulli (Deprecated)
|
||||
---
|
||||
|
||||
:::warning[Deprecated]
|
||||
|
||||
Multi-scrobbler >= 0.8.8 supports a Plex Source that [directly uses the API](configuration/configuration.mdx?plexType=api#plex) which removes the need to use Tautulli since it does not require Plex Pass.
|
||||
|
||||
The Tautulli source **and** Plex Webhook source are deprecated and will be removed in an upcoming release. Existing users should migrate to the API source as soon as possible.
|
||||
|
||||
:::
|
||||
|
||||
Tracks played on [Plex](https://plex.tv/) can be scrobbled either by:
|
||||
* A [Tautulli](https://tautulli.com/) notification agent with a webhook.
|
||||
* Using Plex [Webhooks](https://support.plex.tv/articles/115002267687-webhooks) (restricted to Plex Pass users)
|
||||
|
||||
# Using Tautulli
|
||||
|
||||
## Create a new Notification Agent
|
||||
|
||||
* Navigate to the **Notification Agents** page in **Settings**
|
||||
* Click **Add a new notification agent**
|
||||
* Select **Webhook**
|
||||
|
||||
## Configure the Agent
|
||||
|
||||
The below sections correspond with the tabs available in the notification agent configuration popup.
|
||||
|
||||
### Configuration
|
||||
* Webhook URL -- `http://localhost:9078/tautulli` (substitute your domain if different than the default)
|
||||
* Webhook Method -- POST
|
||||
|
||||
### Triggers
|
||||
|
||||
Select **Watched**
|
||||
|
||||
### Conditions
|
||||
|
||||
Refer to [Tautulli's documentation](https://github.com/Tautulli/Tautulli-Wiki/wiki/Custom-Notification-Conditions) if you need help here. It may be a good idea to restrict notifications to only one library (if you have a Music library, for instance)
|
||||
|
||||
**This app will only scrobble an item if `media_type` is a "track", which is the default for all music.**
|
||||
|
||||
### Data
|
||||
|
||||
Expand the **Watched** dropdown and add the following code block to the **JSON Data** text field:
|
||||
|
||||
```
|
||||
{
|
||||
"artist_name": "{artist_name}",
|
||||
"track_name": "{track_name}",
|
||||
"track_artist": "{track_artist}",
|
||||
"album_name": "{album_name}",
|
||||
"media_type": "{media_type}",
|
||||
"title": "{title}",
|
||||
"duration": "{duration_sec}",
|
||||
"username": "{username}",
|
||||
"server": "{server_name}",
|
||||
"version": "{server_version}",
|
||||
"library": "{library_name}",
|
||||
"player": "{player}",
|
||||
"device": "{device}",
|
||||
"platform": "{platform}",
|
||||
"action": "{action}",
|
||||
"machine_id": "{machine_id}",
|
||||
"session_key": "{session_key}"
|
||||
}
|
||||
```
|
||||
|
||||
**Click the Save button to finish.**
|
||||
|
||||
Your agent is now configured and ready to scrobble.
|
||||
|
||||
# Using Plex Webhooks
|
||||
|
||||
* Navigate to your **Account/Settings** and find the **Webhooks** page
|
||||
* Click **Add Webhook**
|
||||
* URL -- `http://localhost:9078/plex` (substitute your domain if different than the default)
|
||||
* **Save Changes**
|
||||
|
||||
Plex is now configured to scrobble.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"label": "Scrobble Sources",
|
||||
"link": {
|
||||
"type": "doc",
|
||||
"id": "configuration/sources/sources"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Azuracast
|
||||
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 AzuracastConfig from '!!raw-loader!@site/../config/azuracast.json.example';
|
||||
import ShouldScrobble from "@site/src/components/snippets/_should-scrobble.mdx"
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Azuracast may not correctly report track length or position. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](/configuration/sources#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
|
||||
|
||||
:::
|
||||
|
||||
The Azuracast server should have **Use High-Performance Now Playing Updates** enabled in _Administration -> System Settings_
|
||||
|
||||
#### URL
|
||||
|
||||
The URL used by MS to connect to Azuracast has the syntax:
|
||||
|
||||
```
|
||||
[ws|wss]://HOST:[PORT]
|
||||
```
|
||||
|
||||
MS will automatically add the path required for websockets, [`/api/live/nowplaying/websocket`](https://www.azuracast.com/docs/developers/now-playing-data/#websockets), to your URL if none is provided. If you use a reverse proxy with a path-based URL or otherwise need a custom path to access the websockets path correctly then explicitly provide it. Examples:
|
||||
|
||||
```
|
||||
URL From Config => MS Uses
|
||||
|
||||
'ws://192.168.0.101' => ws://192.168.0.101/api/live/nowplaying/websocket
|
||||
'ws://azura.mydomain.com' => ws://azura.mydomain.com.com/api/live/nowplaying/websocket
|
||||
'wss://mydomain.com/custom/azura/ws' => wss://mydomain.com/custom/azura/ws
|
||||
```
|
||||
|
||||
#### Scrobble Behavior
|
||||
|
||||
:::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
|
||||
* listener count (`AZURA_LISTENERS_NUM` or `monitorWhenListeners`)
|
||||
|
||||
If either is configured then MS will automatically scrobble based on when these conditions are met.
|
||||
|
||||
If neither is configured automatic scrobble defaults to **off** and you should either manually initiate it or explicitly set the default behavior.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | :------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `AZ_URL` | Yes | | Azuracast URL *without station name* |
|
||||
| `AZ_STATION` | Yes | | The station name shown on the public page |
|
||||
| `AZURA_LIVE` | No | Yes | Only scrobble when station status is ONLINE |
|
||||
| `AZURA_LISTENERS_NUM` | No | `true` | Only scrobble if station has any listeners (`true`) or listeners are equal-to/greater-than `X` |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: Deezer
|
||||
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 DeezerConfig from '!!raw-loader!@site/../config/deezer.json.example';
|
||||
import DeezerDeprecatedConfig from '!!raw-loader!@site/../config/deezer-deprecated.json.example';
|
||||
|
||||
<Tabs groupId="deezerSource" queryString>
|
||||
<TabItem value="arl" label="Unofficial ARL">
|
||||
:::warning
|
||||
|
||||
This Source uses unofficial methods to access Deezer data that are likely in violation of Deezer's TOS. Deezer may change or remove these methods at any time, breaking functionality. Use this Source at your own risk.
|
||||
|
||||
:::
|
||||
|
||||
:::info
|
||||
|
||||
This Source will work only if you have a **Premium** Deezer account.
|
||||
|
||||
:::
|
||||
|
||||
### Retrieve ARL
|
||||
|
||||
The `arl` is a browser cookie that Deezer uses to store your login information. Multi-scrobbler uses the `arl` value to make authorized requests to Deezer's API where it can retrieve listening history.
|
||||
|
||||
**After logging into Deezer**, use one of the methods below to retrieve the `arl` cookie value:
|
||||
|
||||
<Tabs groupId="arlRetrival">
|
||||
<TabItem value="chrome" label="Chrome">
|
||||
1. On deezer.com, press F12 to open the Developer Tools window.
|
||||
2. Go to the Applications tab. Press the '>>' More tabs icon if it's hidden.
|
||||
3. Under the Cookies Filter, click deezer.com, then the text bar name `arl`.
|
||||
4. Copy all the contents shown in the **Cookie Value** pane.
|
||||
</TabItem>
|
||||
<TabItem value="firefox" label="Firefox">
|
||||
1. On deezer.com, hit Ctrl+Shift+I to open Developer Tools
|
||||
2. Go to the Storage Tab, then expand Cookies in the sidebar and select deezer.com
|
||||
3. Find the row with `arl` as the name, then double click the Value column and right click -> copy the value
|
||||
</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"`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>deezer.json example</summary>
|
||||
|
||||
```json title="deezer.json"
|
||||
[
|
||||
{
|
||||
"name": "MyDeezer",
|
||||
"data": {
|
||||
"arl": "UOsRPjT3U5Dhaaup3x...",
|
||||
},
|
||||
"options": {
|
||||
"fuzzyDiscoveryIgnore": "aggressive"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
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.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="official" label="Official API">
|
||||
|
||||
:::warning
|
||||
|
||||
**This Source is DEPRECATED because Deezer has dropped official API support.** This Source will **not** be removed but no further support or fixes will be given. [See this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
|
||||
|
||||
Users cannot create new applications on Deezer Developers and there is no guarantee existing applications will continue to work.
|
||||
|
||||
As a workaround consider:
|
||||
|
||||
* Using the alternative [Deezer Source that uses unofficial, internal API via ARL](./?deezerSource=arl)
|
||||
* Connect Deezer with Last.fm and then use [Last.fm as a Source](/configuration/sources/lastfm-source)
|
||||
|
||||
:::
|
||||
|
||||
Create a new application at [Deezer Developers](https://developers.deezer.com/myapps)
|
||||
|
||||
* Application Domain must be the same as your multi-scrobbler domain. Default is `localhost:9078`
|
||||
* Redirect URL must end in `deezer/callback`
|
||||
* Default would be `http://localhost:9078/deezer/callback`
|
||||
|
||||
After application creation you should have credentials displayed in the "My Apps" dashboard. You will need:
|
||||
|
||||
* **Application ID**
|
||||
* **Secret Key**
|
||||
* **Redirect URL** (if not the default)
|
||||
|
||||
**If no access token is provided...**
|
||||
|
||||
After starting multi-scrobbler with credentials in-place open the dashboard (`http://localhost:9078`) and find your Deezer source. Click **(Re)authenticate and (re)start polling** to start the login process. After login is complete polling will begin automatically.
|
||||
|
||||
###### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|-----------------------------------------|-----------------------------------|
|
||||
| `DEEZER_CLIENT_ID` | Yes | | Your **Application ID** |
|
||||
| `DEEZER_CLIENT_SECRET` | Yes | | Your **Secret Key** |
|
||||
| `DEEZER_REDIRECT_URI` | No | `http://localhost:9078/deezer/callback` | URI must end in `deezer/callback` |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/deezer.json" language="json5">{DeezerDeprecatedConfig}</CodeBlock>
|
||||
|
||||
|
||||
or <SchemaLink lower objectName="DeezerSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
|
||||
<AIOExample data={DeezerDeprecatedConfig} name="deezer"/>
|
||||
|
||||
|
||||
or <SchemaLink lower objectName="DeezerSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
title: Google Cast (Chromecast)
|
||||
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 ChromecastConfig from '!!raw-loader!@site/../config/chromecast.json.example';
|
||||
|
||||
If your media device can be **Cast** to using this button  on your phone/computer then multi-scrobbler can monitor it in order to scrobble music you play.
|
||||
|
||||
|
||||
:::note
|
||||
|
||||
Google Cast support is **experimental**. You may experience crashes and errors while using this Source. Please open an issue if you experience problems and include all information detailed in the issue template to help debug your issue.
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
This source relies on common, **basic** music data provided by the cast device which will always be less exhaustive than data parsed from full source integrations. If there is an existing [Source](/configuration/sources) it is recommended to configure for it and blacklist the app on Google Cast, rather than relying solely on Google Cast for scrobbling.
|
||||
|
||||
:::
|
||||
|
||||
#### Connecting Devices
|
||||
|
||||
Cast devices can be manually configured using file-based configuration OR automatically discovered using **mDNS.**
|
||||
|
||||
###### mDNS Discovery
|
||||
|
||||
The host machine running multi-scrobbler must be configured to allow [mDNS traffic on port 5353/UDP](https://book.hacktricks.xyz/network-services-pentesting/5353-udp-multicast-dns-mdns).
|
||||
|
||||
<details>
|
||||
|
||||
<summary>OS Specific Instructions</summary>
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="linux" label="Linux">
|
||||
**Docker**
|
||||
|
||||
The host machine must have [avahi-daemon](https://avahi.org/) running to circumvent limitations with DNS resolution due to musl in Alpine. Most major linux distributions package avahi and many have it built-in. Once avahi is running you must pass D-Bus and the avahi daemon socket to your container like so:
|
||||
|
||||
```
|
||||
docker run ... -v /var/run/dbus:/var/run/dbus -v /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket ... foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
**Flatpak/Nodejs**
|
||||
|
||||
No additional steps are required.
|
||||
</TabItem>
|
||||
<TabItem value="windows" label="Windows">
|
||||
**Docker**
|
||||
|
||||
Unsupported at this time.
|
||||
|
||||
**Nodejs**
|
||||
|
||||
No additional steps are required.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
#### What Media Does MS Scrobble?
|
||||
|
||||
Cast devices report what type of media the current activity is [(see `metadata` property here)](https://developers.google.com/cast/docs/media/messages#MediaInformation). The reported type is dependent on the application playing the media to correctly report it, the cast device does not magically know what the media is. If an application does not report a type it is always classified as `unknown`.
|
||||
|
||||
**By default, MS will only track media that is reported as `MusicTrack`.**
|
||||
|
||||
##### Allow Unknown Media Type
|
||||
|
||||
Media with an Unknown (`Generic`) media type can be explicitly allowed by setting `"allowUnknownMedia": true` in the file-based configuration. This can also be configured to only allow unknown media types for specific applications by using a list of application names.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5 title="chromecast.json"
|
||||
[
|
||||
{
|
||||
"name": "MyCast",
|
||||
"type": "chromecast",
|
||||
"data": {
|
||||
// only allow unknown if app name contains any of these phrases
|
||||
"allowUnknownMedia": ["smarttube", "default media receiver"]
|
||||
},
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
##### Forcing Media Tracking
|
||||
|
||||
MS can be forced to track media from an application regardless of media type. This is useful if an application incorrectly reports a media type you are sure should be music. Set `"forceMediaRecognitionOn"` in the file-based configuration to a list of application names that should always be tracked.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5 title="chromecast.json"
|
||||
[
|
||||
{
|
||||
"name": "MyCast",
|
||||
"type": "chromecast",
|
||||
"data": {
|
||||
// media from applications that contains these phrases will always be tracked, regardless of media type reported
|
||||
"forceMediaRecognitionOn": ["smarttube", "default media receiver"]
|
||||
},
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Cast Troubleshooting
|
||||
|
||||
Please include any/all logs with raw output if there are any errors encountered as this is critical to diagnosing issues.
|
||||
|
||||
To diagnose bad/incomplete track information or strange MS player behavior please turn on **payload logging** and include log output of the source running to help diagnose this issue. Either enable in config using the below example OR enable [Debug Mode.](/configuration#debug-mode)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5 title="chromecast.json"
|
||||
[
|
||||
{
|
||||
"name": "MyCast",
|
||||
"type": "chromecast",
|
||||
"data": {
|
||||
//...
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
:::note
|
||||
[Manually configuring cast device connections](#connecting-devices) is only available through file-based config.
|
||||
:::
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|--------------------------------------------------------------------------------------|
|
||||
| CC_ENABLE | No | | Set to 'true' to enable Cast monitoring without needing to define other ENVs |
|
||||
| CC_WHITELIST_DEVICES | No | | Only scrobble from these Cast devices. Comma-delimited list. EX mini-home, family-tv |
|
||||
| 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>
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: Icecast
|
||||
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 IcecastConfig from '!!raw-loader!@site/../config/icecast.json.example';
|
||||
import ShouldScrobble from "@site/src/components/snippets/_should-scrobble.mdx"
|
||||
|
||||
Parse metadata from any Icecast Station (streams) to use for scrobbling.
|
||||
|
||||
:::note
|
||||
|
||||
There is no standard format for presenting track information in an Icecast stream and the majority of streams that do include metadata only use a single string (`StreamTitle`) from which metadata can be parsed.
|
||||
|
||||
Multi-scrobbler will do its best to parse Artist/Album/Track information from this data but the quality and consistency for this is solely based on how the Icecast broadcaster chooses to format their tracks.
|
||||
|
||||
:::
|
||||
|
||||
:::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](/configuration/sources#should-scrobble-behavior) to initiate scrobbling or configure automatic scrobble.
|
||||
|
||||
:::
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Icecast does not report track lengths so multi-scrobbler cannot determine if it should scrobble based on percent played. You should instead set a shorter "listened to" **duration** using [Scrobble Thresholds.](/configuration/sources#scrobble-thresholds)
|
||||
|
||||
:::
|
||||
|
||||
|
||||
#### URL and Sources
|
||||
|
||||
The URL should be the URL you would use to stream audio.
|
||||
|
||||
Additional sources are supported based on those found in [`icecast-metadata-stats`](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#supported-status-apis). To use these with automatic parsing [based on the regular URL](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) include a `sources` array in your config the same way as shown in `icecast-metadata-stats`:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5 title="config.json"
|
||||
[
|
||||
{
|
||||
"name": "myIcy",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://myIcecast.com/radio",
|
||||
"sources": ["icy", "ogg", "sevenhtml"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
To manually set these sources include the same [`*Endpoint` options](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) found in `icecast-metadata-stats`.
|
||||
|
||||
If no `sources` are configured for multi-scrobbler it will automatically try to use `icy` and `ogg`.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | :------ | ----------- |
|
||||
| `ICECAST_URL` | Yes | | Icecast URL |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/icecast.json" language="json5">{IcecastConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="IcecastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={IcecastConfig} name="icecast"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="IcecastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Jellyfin
|
||||
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 JellyfinConfig from '!!raw-loader!@site/../config/jellyfin.json.example';
|
||||
|
||||
Must be using Jellyfin 10.7 or greater
|
||||
|
||||
* Create an **API Key** for multi-scrobbler
|
||||
* In the Jellyfin desktop web UI Navigate to -> Administration -> Dashboard -> API Keys (`http://YOUR_JELLYIN_URL/web/index.html#!/apikeys.html`)
|
||||
* Click `+` button and create a new key with **App name** `multi-scrobbler`
|
||||
* Copy the created API Key value for use in configuration below
|
||||
|
||||
|
||||
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]
|
||||
|
||||
By default...
|
||||
|
||||
* multi-scrobbler will **only** scrobble for the user authenticated with the API.
|
||||
* Allowed Users (`usersAllow` or `JELLYFIN_USERS_ALLOW`) are only necessary if you want to scrobble for additional users.
|
||||
* multi-scrobbler will **only** scrobble media found in Jellyfin libraries that were labelled as **Music.**
|
||||
* `librariesAllow` or `JELLYFIN_LIBRARIES_ALLOW` will override this
|
||||
* OR use `additionalAllowedLibraryTypes` to allow more types (like `mixed` or `book` for audiobooks)
|
||||
* multi-scrobbler will **only** scrobble media Jellyfin detects as **Audio.**
|
||||
* To force multi-scrobbler to scrobble when media is detected as **Unknown** use `"allowUnknown": true` in file/aio configuration.
|
||||
|
||||
:::
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| -------------------------- | --------- | ------- | ------------------------------------------------------------------------------------------ |
|
||||
| `JELLYFIN_URL` | **Yes** | | The URL of the Jellyfin server IE `http://localhost:8096` |
|
||||
| `JELLYFIN_USER` | **Yes** | | The user to authenticate with the API |
|
||||
| `JELLYFIN_APIKEY` | No | | The API Key to use for authentication **(Must provide either apikey or password)** |
|
||||
| `JELLYFIN_PASSWORD` | No | | The password of the user to authenticate for. **(Must provide either apikey or password)** |
|
||||
| `JELLYFIN_USERS_ALLOW` | No | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
|
||||
| `JELLYFIN_USERS_BLOCK` | No | | Comma-separated list of usernames (from Jellyfin) to disallow scrobble for |
|
||||
| `JELLYFIN_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from |
|
||||
| `JELLYFIN_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from |
|
||||
| `JELLYFIN_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from |
|
||||
| `JELLYFIN_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: JRiver
|
||||
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 JriverConfig from '!!raw-loader!@site/../config/jriver.json.example';
|
||||
|
||||
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can can be in the JRiver GUI:
|
||||
|
||||
* Tools -> Options -> Media Network
|
||||
* Check `Use Media Network to share this library...`
|
||||
* If you have `Authentication` checked you will need to provide the **Username** and **Password** in the ENV/File configuration below.
|
||||
|
||||
### URL
|
||||
|
||||
The URL used to connect to JRiver, set in your [file config as `url`.](#configuration) The URL used to connect ultimately must be formed like this:
|
||||
|
||||
```
|
||||
Syntax => [protocol]://[hostname]:[port]/[path]
|
||||
Default => http://localhost:52199/MCWS/v1/
|
||||
```
|
||||
|
||||
If any part of this URL is missing multi-scrobbler will use a default value. This also means that if any part of your URL is **not** standard you must explicitly define it.
|
||||
|
||||
:::tip[Jriver Settings]
|
||||
|
||||
* Make sure the port number matches what is found in `Advanced` section in the Media Network options.
|
||||
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
<summary>URL Transform Examples</summary>
|
||||
|
||||
Examples of a given `url` in your file config and the final URL multi-scrobbler will use to connect to JRiver:
|
||||
|
||||
| `url` | Transformed URL |
|
||||
|------------------------------------------|---------------------------------------------|
|
||||
| (none set) | `http://localhost:52199/MCWS/v1/` |
|
||||
| `jriver.mydomain.com` | `http://jriver.mydomain.com:52199/MCWS/v1/` |
|
||||
| `192.168.0.101:3456` | `http://192.168.0.101:3456/MCWS/v1/` |
|
||||
| `mydomain.com:80/jriverReverse/MCWS/v1/` | `mydomain.com:80/jriverReverse/MCWS/v1/` |
|
||||
|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
<EnvType>
|
||||
| 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>
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Kodi
|
||||
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 KodiConfig from '!!raw-loader!@site/../config/kodi.json.example';
|
||||
import Config from '@site/src/components/GenericConfiguration.mdx';
|
||||
|
||||
In order for multi-scrobbler to communicate with Kodi you must have the [Web Interface](https://kodi.wiki/view/Web_interface) enabled. This can be done in the Kodi GUI:
|
||||
|
||||
* Settings -> Services -> Control
|
||||
* Check `Allow remote control via HTTP`
|
||||
* Ensure you have a **Username** and **Password** set, you will need to provide them in the ENV/File configuration below.
|
||||
|
||||
### URL
|
||||
|
||||
The URL used to connect to Kodi, set in your [file config as `url`.](#configuration) The URL used to connect ultimately must be formed like this:
|
||||
|
||||
```
|
||||
Syntax => [protocol]://[hostname]:[port]/[path]
|
||||
Default => http://localhost:8080/jsonrpc
|
||||
```
|
||||
|
||||
If any part of this URL is missing multi-scrobbler will use a default value. This also means that if any part of your URL is **not** standard you must explicitly define it.
|
||||
|
||||
:::tip[Kodi Settings]
|
||||
|
||||
* Make sure the port number matches what is found in **Port** in the Control section mentioned above.
|
||||
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
<summary>URL Transform Examples</summary>
|
||||
|
||||
Examples of a given `url` in your file config and the final URL multi-scrobbler will use to connect to Kodi:
|
||||
|
||||
| `url` | Transformed URL |
|
||||
|---------------------------------------|----------------------------------------------|
|
||||
| (none set) | `http://localhost:8080/jsonrpc` |
|
||||
| `kodi.mydomain.com` | `http://kodi.mydomain.com:8080/jsonrpc` |
|
||||
| `192.168.0.101:3456` | `http://192.168.0.101:3456/jsonprc` |
|
||||
| `mydomain.com:80/kodiReverse/jsonrpc` | `http://mydomain.com:80/kodiReverse/jsonrpc` |
|
||||
|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="KodiSourceConfig" fileContent={KodiConfig} name="kodi">
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|-------------------------------|----------------------------|
|
||||
| KODI_URL | Yes | http://localhost:8080/jsonrpc | The URL of the Kodi server |
|
||||
| KODI_USERNAME | No | | The username set |
|
||||
| KODI_PASSWORD | No | | The password set |
|
||||
</Config>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Koito
|
||||
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 KoitoConfig from '!!raw-loader!@site/../config/koito.json.example';
|
||||
|
||||
This Source monitors a Koito account's scrobble history and then re-scrobbles discovered tracks to configured [Clients.](/configuration/clients)
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
To _scrobble to_ a Koito server, create a [Koito (Client)](/configuration/clients/koito)
|
||||
|
||||
:::
|
||||
|
||||
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.
|
||||
:::
|
||||
</Config>
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Lastfm (Endpoint)
|
||||
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 LastfmEndpointConfig from '!!raw-loader!@site/../config/endpointlfm.json.example';
|
||||
|
||||
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Last.fm server.
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
* To **scrobble to** Last.fm, create a [Last.fm (Client)](/configuration/clients/lastfm)
|
||||
* To monitor and re-scrobble activity from your Last.fm account, create a [Last.fm (Source)](/configuration/sources/lastfm-source)
|
||||
|
||||
:::
|
||||
|
||||
### URL
|
||||
|
||||
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Last.fm scrobbles at
|
||||
|
||||
```
|
||||
http://localhost:9078/2.0/
|
||||
```
|
||||
|
||||
which is the "standard" Last.fm server path for scrobbling
|
||||
|
||||
Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens.
|
||||
|
||||
If a slug is used then the URL will be:
|
||||
|
||||
```
|
||||
http://localhost:9078/api/lastfm/mySlug
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LastFMEndpointSourceConfig" fileContent={LastfmEndpointConfig} name="endpointlfm">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `LFMENDPOINT_ENABLE` | No | | Use LFM Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
|
||||
| `LFM_SLUG` | No | | (Optional) The URL suffix to use for accepting LFM scrobbles |
|
||||
</Config>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Lastfm
|
||||
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 LastfmConfig from '!!raw-loader!@site/../config/lastfm.json.example';
|
||||
|
||||
This Source **monitors your Last.fm scrobble history** and re-scrobbles new activity to configured [Clients.](/configuration/clients)
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
* To **scrobble to** Last.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)
|
||||
|
||||
:::
|
||||
|
||||
To configure this Source use the same registration instructions and configuration data shown in [Last.fm (Client)](/configuration/clients/lastfm) with the difference being `"configureAs": "source"` defined in the File/AIO configuration below.
|
||||
|
||||
## 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.
|
||||
:::
|
||||
</Config>
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Listenbrainz (Endpoint)
|
||||
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 ListenbrainzEndpointConfig from '!!raw-loader!@site/../config/endpointlz.json.example';
|
||||
|
||||
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Listenbrainz server.
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
* To **scrobble to** Listenbrainz, create a [Listenbrainz (Client)](/configuration/clients/listenbrainz)
|
||||
* To monitor and re-scrobble activity from your Listenbrainz account, create a [Listenbrainz (Source)](/configuration/sources/listenbrainz-source)
|
||||
|
||||
:::
|
||||
|
||||
### URL
|
||||
|
||||
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Listenbrainz scrobbles at
|
||||
|
||||
```
|
||||
http://myMultiScrobblerIP:9078/1/submit-listens
|
||||
```
|
||||
|
||||
which is the "standard" Listenbrainz server path for scrobbling.
|
||||
|
||||
In general, you should use `http://myMultiScrobblerIP:9078/1/` as the **base path** when configuring your application to scrobble to multi-scrobbler.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>URL Setup for Popular Apps</summary>
|
||||
|
||||
##### Navidrome
|
||||
|
||||
Set the [**ListenBrainz.BaseURL**](https://www.navidrome.org/docs/usage/configuration-options/#advanced-configuration) (env `ND_LISTENBRAINZ_BASEURL`) to `http://myMultiScrobblerIP:9078/1/`
|
||||
|
||||
</details>
|
||||
|
||||
Use a slug only if you need to setup multiple Listenbrainz Endpoint sources and cannot use different tokens.
|
||||
|
||||
If a slug is used then the URL will be:
|
||||
|
||||
```
|
||||
http://myMultiScrobblerIP:9078/api/listenbrainz/mySlug
|
||||
```
|
||||
|
||||
:::note
|
||||
Some Listenbrainz applications may require custom Listenbrainz URLs to be a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality. It is out of the scope of this project to do this solely within multi-scrobbler.
|
||||
:::
|
||||
|
||||
### Token
|
||||
|
||||
Most Listenbrainz clients require a token (Authentication Token) to be provided during setup. This value can be anything you want, just make sure to use the same value for `token` in your multi-scrobbler configuration for the endpoint.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenbrainzEndpointSourceConfig" fileContent={ListenbrainzEndpointConfig} name="endpointlz">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
|
||||
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
|
||||
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
|
||||
</Config>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Listenbrainz
|
||||
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 ListenbrainzConfig from '!!raw-loader!@site/../config/listenbrainz.json.example';
|
||||
|
||||
This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients.
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
* To **scrobble to** a Listenbrainz server, create a [Listenbrainz (Client)](/configuration/clients/listenbrainz)
|
||||
* To accept scrobbles from outside applications as if multi-scrobbler was a Listenbrainz server, create a [Listenbrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint)
|
||||
|
||||
:::
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/).
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
|
||||
## 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.
|
||||
:::
|
||||
</Config>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Maloja
|
||||
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 MalojaConfig from '!!raw-loader!@site/../config/maloja.json.example';
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
To _scrobble to_ a Maloja server, create a [Maloja (Client)](/configuration/clients/maloja)
|
||||
|
||||
:::
|
||||
|
||||
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.
|
||||
:::
|
||||
</Config>
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Mopidy
|
||||
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 MopidyConfig from '!!raw-loader!@site/../config/mopidy.json.example';
|
||||
|
||||
Mopidy is a headless music server that supports playing music from many [standard and non-standard sources such as Pandora, Bandcamp, and Tunein.](https://mopidy.com/ext/)
|
||||
|
||||
multi-scrobbler can scrobble tracks played from any Mopidy backend source, regardless of where you listen to them.
|
||||
|
||||
:::note
|
||||
|
||||
You must have [Mopidy-HTTP extension](https://mopidy.com/ext/http) enabled for this integration to work.
|
||||
|
||||
:::
|
||||
|
||||
#### URL
|
||||
|
||||
The URL used to connect to the Mopidy server, set in your [file config as `url`.](#configuration) The URL used to connect ultimately must be formed like this:
|
||||
|
||||
```
|
||||
Syntax => [protocol]://[hostname]:[port]/[path]
|
||||
Default => ws://localhost:6680/mopidy/ws
|
||||
```
|
||||
|
||||
If any part of this URL is missing multi-scrobbler will use a default value. This also means that if any part of your URL is **not** standard you must explicitly define it.
|
||||
|
||||
:::tip[Mopidy Configuration]
|
||||
|
||||
Make sure the hostname and port number match what is found in the Mopidy configuration file `mopidy.conf`:
|
||||
|
||||
```ini
|
||||
[http]
|
||||
hostname = localhost
|
||||
port = 6680
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
<summary>URL Transform Examples</summary>
|
||||
|
||||
Examples of a given `url` in your file config and the final URL multi-scrobbler will use to connect to Mopidy:
|
||||
|
||||
| `url` | Transformed URL |
|
||||
|--------------------------------|--------------------------------------------|
|
||||
| (none set) | `ws://localhost:6680/mopidy/ws` |
|
||||
| `mopidy.mydomain.com` | `ws://mopidy.mydomain.com:6680/mopidy/ws/` |
|
||||
| `192.168.0.101:3456` | `ws://192.168.0.101:3456/mopidy/ws/` |
|
||||
| `mopidy.mydomain.com:80/MOPWS` | `ws://mopidy.mydomain.com:80/MOPWS` |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
### URI Blacklist/Whitelist
|
||||
|
||||
If you wish to disallow or only allow scrobbling from some sources played through Mopidy you can specify these using `uriBlacklist` or `uriWhitelist` in your config. multi-scrobbler will check the list to see if any string matches the START of the `uri` on a track. If whitelist is used then blacklist is ignored. All strings are case-insensitive.
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
```json
|
||||
{
|
||||
"uriBlacklist": ["soundcloud"]
|
||||
}
|
||||
```
|
||||
|
||||
Will prevent multi-scrobbler from scrobbling any Mopidy track that start with a `uri` like `soundcloud:song:MySong-1234`
|
||||
</details>
|
||||
|
||||
|
||||
### Album Blacklist
|
||||
|
||||
For certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use "Soundcloud" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake Album name to this list. Multi-scrobbler will still scrobble the track, just without the bad data. All strings are case-insensitive.
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
```json
|
||||
{
|
||||
"albumBlacklist": ["SoundCloud", "Mixcloud"]
|
||||
}
|
||||
```
|
||||
|
||||
If a track would be scrobbled like
|
||||
```
|
||||
Album: Soundcloud, Track: My Cool Track, Artist: A Cool Artist
|
||||
```
|
||||
then multi-scrobbler will instead scrobble
|
||||
```
|
||||
Track: My Cool Track, Artist: A Cool Artist
|
||||
```
|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MopidySourceConfig" fileContent={MopidyConfig} name="mopidy">
|
||||
No ENV support
|
||||
</Config>
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: MPD (Music Player Daemon)
|
||||
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 MPDConfig from '!!raw-loader!@site/../config/mpd.json.example';
|
||||
|
||||
MS communicates with MPD using the [TCP client connection.](https://mpd.readthedocs.io/en/stable/user.html#client-connections)
|
||||
|
||||
You should uncomment/create the following settings in your mpd config:
|
||||
|
||||
```
|
||||
bind_to_address "any" # or a specific ipv4/v6 address
|
||||
port "6600"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MPDSourceConfig" fileContent={MPDConfig} name="mpd">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|------------------|-------------|
|
||||
| `MPD_URL` | No | `localhost:6600` | |
|
||||
| `MPD_PASSWORD` | No | | |
|
||||
</Config>
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: MPRIS
|
||||
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 MPRISConfig from '!!raw-loader!@site/../config/mpris.json.example';
|
||||
|
||||
[MPRIS](https://specifications.freedesktop.org/mpris-spec/latest) is a standard interface for communicating with Music Players on **linux operating systems.**
|
||||
|
||||
If you run Linux and have a notification tray that shows what media you are listening to, you likely have access to MPRIS.
|
||||
|
||||

|
||||
|
||||
multi-scrobbler can listen to this interface and scrobble tracks played by **any media player** that communicates to the operating system with MPRIS.
|
||||
|
||||
##### Host Setup
|
||||
|
||||
MPRIS communication requires multi-scrobbler to have access to the host's dbus-daemon.
|
||||
|
||||
<Tabs groupId="dbus" queryString>
|
||||
<TabItem value="local" label="Local">
|
||||
If multi-scrobbler is running as a [**Local Installation**](../../installation/installation.mdx#nodejs), directly with node/npm, on the same host as MPRIS then no setup is required.
|
||||
|
||||
Remote setup is also possible, see the **Remote Unix Socket** section in the Docker tab.
|
||||
</TabItem>
|
||||
<TabItem value="docker" label="Docker">
|
||||
If multi-scrobbler is running as a [**Docker Container**](../../installation/installation.mdx#docker) some modifications are required...
|
||||
|
||||
##### Local Unix Socket
|
||||
|
||||
The most secure way is to **run a multi-scrobbler container on the same host as MPRIS.** In this scenario you can simply mount the **user session** dbus unix socket into the container. Make sure [`PUID` and `PGID` ENVs are configured](/installation/#linux-host) so the container has permissions to access the socket. Then, modify your compose stack with these additional values:
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
|
||||
environment:
|
||||
- DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/UID/bus
|
||||
|
||||
volumes:
|
||||
- /run/user/UID/bus:/run/user/UID/bus:ro
|
||||
```
|
||||
|
||||
##### Remote Unix Socket
|
||||
|
||||
:::warning
|
||||
|
||||
This method is **insecure.** You should not expose dbus over a network unless access to the port is restricted and the network is trusted.
|
||||
|
||||
:::
|
||||
|
||||
Use [`socat`](https://linux.die.net/man/1/socat) to bidirectionally relay communication from the host's dbus socket to a listening TCP port.
|
||||
|
||||
To make dbus for user with uid `1000` available on port `6644`:
|
||||
|
||||
```shell
|
||||
socat TCP-LISTEN:6644,reuseaddr,fork UNIX-CONNECT:/run/user/1000/bus
|
||||
```
|
||||
|
||||
Then, add the following value to multi-scrobbler's ENVs:
|
||||
|
||||
```
|
||||
DBUS_SESSION_BUS_ADDRESS=tcp:host=yourHostIP,port=6644
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MPRISSourceConfig" fileContent={MPRISConfig} name="mpris">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|----------------------------------------------------------------------------------|
|
||||
| MPRIS_ENABLE | No | | Use MPRIS as a Source (useful when you don't need any other options) |
|
||||
| MPRIS_BLACKLIST | No | | Comma-delimited list of player names not to scrobble from |
|
||||
| MPRIS_WHITELIST | No | | Comma-delimited list of players names to ONLY scrobble from. Overrides blacklist |
|
||||
</Config>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Musikcube
|
||||
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 MusikcubeConfig from '!!raw-loader!@site/../config/musikcube.json.example';
|
||||
|
||||
In order to use [Musikcube](https://musikcube.com) configure it to accept [websocket connections](https://github.com/clangen/musikcube/wiki/remote-api-documentation) in **server setup**:
|
||||
|
||||
* Enable the **Metadata Server**
|
||||
* Set a **Password**
|
||||
|
||||
Both of these settings are found in _Musikcube -> (s)ettings -> server setup_
|
||||
|
||||

|
||||
|
||||
The URL used by MS has the syntax:
|
||||
|
||||
```
|
||||
[ws|wss]://HOST:[PORT]
|
||||
```
|
||||
|
||||
The **port** is the same as shown in the server setup screenshot from above, under **metadata server enabled**. If no port is provided to MS it will default to `7905`.
|
||||
|
||||
If no URL is provided to MS it will try to use `ws://localhost:7905`
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MusikcubeSourceConfig" fileContent={MusikcubeConfig} name="musikcube">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|-----------------------|--------------------------------------|
|
||||
| `MC_URL` | No | `ws://localhost:7905` | Use port set for **metadata server** |
|
||||
| `MC_PASSWORD` | Yes | | |
|
||||
</Config>
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Plex
|
||||
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 AzuracastConfig from '!!raw-loader!@site/../config/azuracast.json.example';
|
||||
import PlexConfig from '!!raw-loader!@site/../config/plex.json.example';
|
||||
|
||||
Find your [**Plex Token**](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) and make note of the **URL** and **Port** used to connect to your Plex instance.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Allowed Users and Defaults</summary>
|
||||
|
||||
**Multi-scrobbler will automatically scrobble for these users by default:**
|
||||
|
||||
* The User authenticated with the Plex Token
|
||||
* and the **Local User**
|
||||
|
||||
The Local User (`PLEX_LOCAL_USER`) is how Plex identifies anyone directly accessing the Plex UI from a local IP (who does not need to login).
|
||||
|
||||
To allow MS to scrobble for other users use `usersAllow` or `PLEX_USERS_ALLOW` (env) from the below configuration docs. However, because you are overriding the default settings you must also explicitly list the authenticated user and the Local User if you want them to also be able to scrobble.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
###### Defaults
|
||||
|
||||
If `usersallow` and `PLEX_USERS_ALLOW` are not defined then the Plex Token authenticated User and Local User will be scrobbled for.
|
||||
|
||||
|
||||
###### Only A Specific User
|
||||
|
||||
* `"usersallow": ["SomeUser"]` or
|
||||
* `PLEX_USERS_ALLOW: SomeUser`
|
||||
|
||||
Only the Plex user `SomeUser` will be scrobbled for. The Plex Token authenticated user and the Local User will not be scrobbled for.
|
||||
|
||||
###### A Specific User + Defaults
|
||||
|
||||
(Assuming the plex authenticated user is `FoxxMD`)
|
||||
|
||||
* `"usersallow": ["FoxxMD", "PLEX_LOCAL_USER", "SomeUser"]` or
|
||||
* `PLEX_USERS_ALLOW: FoxxMD,PLEX_LOCAL_USER,SomeUser`
|
||||
|
||||
The Plex user SomeUser, the Plex Token authenticated user (FoxxMD) and the Local User will be scrobbled for.
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Allowed Libraries and Defaults</summary>
|
||||
|
||||
By default multi-scrobbler will only scrobble media found in Plex libraries that are labelled as **Music.**
|
||||
* `librariesAllow` or `PLEX_LIBRARIES_ALLOW` will override this
|
||||
|
||||
</details>
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Config config="PlexApiSourceConfig" fileContent={PlexConfig} name="plex">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ---------------------- | --------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `PLEX_URL` | **Yes** | | The URL of the Plex server IE `http://localhost:32400` |
|
||||
| `PLEX_TOKEN` | **Yes** | | The **Plex Token** to use with the API |
|
||||
| `PLEX_USERS_ALLOW` | No | | Comma-separated list of usernames (from Plex) to scrobble for |
|
||||
| `PLEX_USERS_BLOCK` | No | | Comma-separated list of usernames (from Plex) to disallow scrobble for |
|
||||
| `PLEX_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from |
|
||||
| `PLEX_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from |
|
||||
| `PLEX_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from |
|
||||
| `PLEX_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from |
|
||||
</Config>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Rocksky
|
||||
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/rocksky.json.example';
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
* To **scrobble to** Rocksky, create a [Rocksky (Client)](/configuration/clients/rocksky)
|
||||
|
||||
:::
|
||||
|
||||
[**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.
|
||||
|
||||
Learn more about how Rocksky scrobbles work in the [Rocksky (Client)](/configuration/clients/rocksky) docs.
|
||||
|
||||
## Setup
|
||||
|
||||
Follow the **Setup** instructions in the [Rocksky (Client)](/configuration/clients/rocksky#setup) docs to get access to your Bluesky PDS repository and scrobble data.
|
||||
|
||||
## Configuration
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Overview
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
import ScrobbleThreshold from "@site/src/components/snippets/_scrobble-threshold.mdx"
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
A **Source** is a data source that contains information about tracks you are playing like a music player or platform. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Airsonic**, etc...
|
||||
|
||||
## Sources
|
||||
|
||||
| Name | Networking | Scrobble SOT | [Multi Device/User](#multi-deviceuser) | [Scrobble Destination](#limiting-scrobble-destination) | [Thresholds](#scrobble-thresholds) | [Should Scrobble](#should-scrobble-behavior) |
|
||||
| :---------------------------------------------------------------------- | :------------------------------------------------------- | :-------------------------------------------------- | :------------------------------------- | :----------------------------------------------------- | :--------------------------------- | :------------------------------------------- |
|
||||
| [Azuracast](/configuration/sources/azuracast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ |
|
||||
| [Deezer](/configuration/sources/deezer) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ✅ | ✅ | ❌ | ❌ |
|
||||
| [Google Cast (Chromecast)](/configuration/sources/google-cast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ |
|
||||
| [Icecast](/configuration/sources/icecast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ |
|
||||
| [Jellyfin](/configuration/sources/jellyfin) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ |
|
||||
| [JRiver](/configuration/sources/jriver) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
|
||||
| [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) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [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) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [Maloja](/configuration/sources/maloja) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [Mopidy](/configuration/sources/mopidy) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
|
||||
| [MPD (Music Player Daemon)](/configuration/sources/mpd) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
|
||||
| [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) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [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) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [WebScrobbler](/configuration/sources/webscrobbler) | [Ingress](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
| [VLC](/configuration/sources/vlc) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
|
||||
| [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
|
||||
| [Youtube Music](/configuration/sources/youtube-music) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
|
||||
|
||||
## Features
|
||||
|
||||
### Types of Sources
|
||||
|
||||
The Sources implemented in multi-scrobbler can be broken down into two categories.
|
||||
|
||||
#### By Communication Method
|
||||
|
||||
How does this Source get data from the service/application?
|
||||
|
||||
<Tabs groupId="sourceComm" queryString>
|
||||
<TabItem value="active" label="Active">
|
||||
The MS Source makes network requests to the service which returns a response with data MS can use to monitor listening activity or scrobbles.
|
||||
</TabItem>
|
||||
<TabItem value="ingress" label="Ingress">
|
||||
The MS Source waits for the service/application to send data to multi-scrobbler.
|
||||
|
||||
These types of Sources require that multi-scrobbler is accessible to the service, generally through an open port or reverse proxy.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
#### By Data Source of Truth
|
||||
|
||||
How does MS determine if/when a song has been played and is scrobbable?
|
||||
|
||||
<Tabs groupId="sot" queryString>
|
||||
<TabItem value="activity" label="Listening Activity">
|
||||
These Sources expose some kind of real-time listening data for users, like:
|
||||
|
||||
* player state (paused, stopped, playing)
|
||||
* player position (1:50/2:40)
|
||||
* current listened duration (Track A playing for 1m:20s)
|
||||
|
||||
For theses Sources multi-scrobbler keeps track of the real-time state of your activity and scrobbles a track after it meets certain [scrobble thresholds](#scrobble-thresholds).
|
||||
</TabItem>
|
||||
<TabItem value="history" label="Listening History">
|
||||
Some Sources expose their own version of "this track has been scrobbled."
|
||||
|
||||
* Listenbrainz, Koito, and Last.fm are all scrobble services that have "definitive" histories of scrobbled tracks.
|
||||
|
||||
For these Sources multi-scrobbler monitors the listening history data and scrobbles when it sees a new entry in the list.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Multi-Device/User
|
||||
|
||||
Some Sources report which User is playing a track and/or on what device the track is being played. These Sources can be filtered with configuration to only monitor activity for specific devices or users.
|
||||
|
||||
### Limiting Scrobble Destination
|
||||
|
||||
To limit which Clients a Source will scrobble to use the `clients` property in a Source's [File/AIO configuration](/configuration?configType=file#configuration-types) to specify the **name** property of the Clients that it should scrobble to:
|
||||
|
||||
```json5 title="jellyfin.json"
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"clients": ["myMaloja","koitoA","listenbrainzFoo"]
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
If `clients` is empty (`"clients": []`) or is omitted then the default is for the Source to scrobble to **all** configured Clients.
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
If a Client is configured using [ENV](/configuration?configType=env#configuration-types) then it is given a generated name. Check the multi-scrobbler Dashboard to find their name in a subheader under the Client type. Example:
|
||||
|
||||

|
||||
|
||||
Otherwise, use the `name` property you gave the client in it's File/AIO config.
|
||||
|
||||
:::
|
||||
|
||||
### Scrobble Thresholds
|
||||
|
||||
<ScrobbleThreshold filename="spotify"/>
|
||||
|
||||
These can also be configured **globally** for all Sources using ENVs:
|
||||
|
||||
```
|
||||
SOURCE_SCROBBLE_DURATION=40
|
||||
SOURCE_SCROBBLE_PERCENT=20
|
||||
```
|
||||
|
||||
### Should Scrobble Behavior
|
||||
|
||||
:::note
|
||||
|
||||
This feature is currently available only for some Sources. Check each Source's documentation to see if it supported.
|
||||
|
||||
:::
|
||||
|
||||
For _most_ Sources multi-scrobbler can determine if a user is listening and scrobble automatically. For Sources which multi-scrobbler cannot do this or require additional configuration, a user can indicate to MS that it should or should not scrobble. This action can be taken on the Web Dashboard by clicking the link next to **Should Scrobble** found in each Source's status card.
|
||||
|
||||
Additionally, for these Sources, the default behavior for Scrobbling can be explicitly set by adding the following value to a Source's `options` in [file or aio config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="azuracast.json"
|
||||
{
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
// set to true to always scrobble automatically
|
||||
// set to false to never scrobble automatically
|
||||
"systemScrobble": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Spotify
|
||||
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 SpotifyConfig from '!!raw-loader!@site/../config/spotify.json.example';
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Spotify and Automix</summary>
|
||||
|
||||
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>
|
||||
|
||||
#### Authenticate Spotify with Multi-Scrobbler
|
||||
|
||||
To access your Spotify history you must [create a Spotify App](https://developer.spotify.com/dashboard) to get a
|
||||
Client ID/Secret.
|
||||
|
||||
1. **Login to https://developer.spotify.com** with your existing Spotify account and accept Developer Terms
|
||||
|
||||
2. Navigate to your Spotify Developer [Dashboard](https://developer.spotify.com/dashboard) and start the **Create App** process
|
||||
|
||||
3. Determine the correct **Redirect URI** to use and set it in your App settings
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Redirect URI Instructions</summary>
|
||||
|
||||
A Redirect URI is the URL that Spotify will navigate your **browser** to after you complete authorization. The URL will contain the code necessary for multi-scrobbler to get a Spotify access token.
|
||||
|
||||
Spotify [**no longer allows insecure URIs**](https://developer.spotify.com/documentation/web-api/tutorials/migration-insecure-redirect-uri) (start with `http://`) unless the address is `127.0.0.1` -- so `localhost` and internal IPs (`192.168.0.xxx`) no longer work.
|
||||
|
||||
Use **one** of the following methods to specify a valid Redirect URI and complete Spotify authentication with multi-scrobbler:
|
||||
|
||||
<Tabs groupId="spotifyRedirect" queryString>
|
||||
<TabItem value="https" label="HTTPS">
|
||||
**Use a domain you control, with SSL certificates/HTTPS enabled, to create a valid redirect URI.**
|
||||
|
||||
For example, if you are already running multi-scrobbler behind a reverse proxy (nginx/traefik/caddy) at `https://scrobbler.mydomain.com` then set the Spotify Redirect URI for your Spotify App, and multi-scrobbler `SPOTIFY_REDIRECT_URI` ENV, to `https://scrobbler.mydomain.com/callback`
|
||||
|
||||
Alternatively, if multi-scrobbler is not accessible behind your domain, then use the [**Echo**](./?spotifyRedirect=echo) method with your own domain instead of 127.0.0.1.
|
||||
</TabItem>
|
||||
<TabItem value="local" label="Run MS Locally">
|
||||
**Run multi-scrobbler, configured for a Spotify Source, from the same machine as your browser in order to get credentials.**
|
||||
|
||||
In this scenario you can use `http://127.0.0.1:9078/callback` as the Spotify Redirect URI and multi-scrobbler `SPOTIFY_REDIRECT_URI` ENV.
|
||||
|
||||
After completing authentication, copy the contents of local multi-scrobbler's [configuration folder](/installation#storage) to the final location of where multi-scrobbler will be run.
|
||||
</TabItem>
|
||||
<TabItem value="echo" label="Echo">
|
||||
**Use a docker container on the same machine your browser is on to "intercept" the callback URL from Spotify authentication, then manually finish the process by changing the domain to your multi-scrobbler instance.**
|
||||
|
||||
|
||||
1. Create a container on the same machine as your browser using the [`mendhak/http-https-echo:36`](https://github.com/mendhak/docker-http-https-echo) image. This container runs a simple HTTP server that echos back any request it recieves.
|
||||
|
||||
```shell
|
||||
docker run --rm -p 127.0.0.1:5079:8080 mendhak/http-https-echo:36
|
||||
```
|
||||
|
||||
2. Set the Spotify Redirect URI and multi-scrobbler `SPOTIFY_REDIRECT_URI` ENV as `http://127.0.0.1:5079/callback`
|
||||
|
||||
3. In [Authenticate Spotify with Multi-Scrobbler](#authenticate-spotify-with-multi-scrobbler) (outside these Redirect Instructions) go to **Step 4** and continue the instructions
|
||||
|
||||
Eventually, Spotify will redirect you to a web page with a URL like `http://127.0.0.1:5079/callback?code=xxxxxx...`
|
||||
|
||||

|
||||
|
||||
4. In the URL, replace `127.0.0.1:5079` with the address/IP for your multi-scrobbler instance so the URL is similar to `http://192.168.0.101:9078/callback?code=xxxxxx...`. Then navigate to this URL to complete authentication.
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="msEcho" label="Echo from echo.multi-scrobbler.app">
|
||||
For your convenience, I run the [`mendhak/http-https-echo:36`](https://github.com/mendhak/docker-http-https-echo) container from the [**Echo**](./?spotifyRedirect=echo) approach on a domain I, the multi-scrobbler developer [FoxxMD](https://github.com/FoxxMD), control and self-host. Please be aware:
|
||||
|
||||
* I **do not collect personal data** or any querystrings/data sent in requests to this site
|
||||
* It's **not possible** for me to use the code sent by Spotify to this URL without also having your Client ID/Secret
|
||||
* However, there is **always** risk involved with trusting a service you do not control. Evaluate the risk of using this site for yourself.
|
||||
|
||||
Use the [**Echo**](./?spotifyRedirect=echo) approach, starting from **Step 2**, with **https://echo.multi-scrobbler.app/callback** as Spotify Redirect URI and multi-scrobbler `SPOTIFY_REDIRECT_URI` ENV.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
4. Save your new App, then copy the Client ID/Secret from the App's Basic Information page.
|
||||
|
||||
5. Add the Client ID, Secret, and Redirect URI to the respective field/ENV in the [configuration section below](#configuration)
|
||||
|
||||
6. Start multi-scrobbler, then visit the Web Dashboard and click **(Re)authenticate** on the Spotify card to start the auth process
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="SpotifySourceConfig" fileContent={SpotifyConfig} name="spotify">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ----------------------- | --------- | -------------------------------- | -------------------------- |
|
||||
| `SPOTIFY_CLIENT_ID` | Yes | | |
|
||||
| `SPOTIFY_CLIENT_SECRET` | Yes | | |
|
||||
| `SPOTIFY_REDIRECT_URI` | No | `http://localhost:9078/callback` | URI must end in `callback` |
|
||||
</Config>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Subsonic
|
||||
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 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]
|
||||
|
||||
* 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**
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
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">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|--------------------------------------|
|
||||
| `SUBSONIC_USER` | Yes | | |
|
||||
| `SUBSONIC_PASSWORD` | Yes | | |
|
||||
| `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server |
|
||||
</Config>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Teal.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/tealfm.json.example';
|
||||
|
||||
:::tip[Other Uses]
|
||||
|
||||
* To **scrobble to** an ATProto PDS, create a [teal.fm (Client)](/configuration/clients/tealfm)
|
||||
|
||||
:::
|
||||
|
||||
[**teal.fm**](https://teal.fm/) is a social discovery and web viewing service for scrobbles stored/available on the ATProto network/your repository in a [PDS (Personal Data Server)](https://github.com/Bluesky-social/pds).
|
||||
|
||||
This Source monitors the teal.fm scrobbles in your PDS and then re-scrobbles discovered tracks to configured clients.
|
||||
|
||||
Learn more about how teal.fm scrobbles work in the [teal.fm (Client)](/configuration/clients/tealfm) docs.
|
||||
|
||||
## Setup
|
||||
|
||||
Follow the **Setup** instructions in the [teal.fm (Client)](/configuration/clients/tealfm#setup) docs to get access to your Bluesky PDS repository and scrobble data.
|
||||
|
||||
## Configuration
|
||||
|
||||
See the [teal.fm (Client)](/configuration/clients/tealfm#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="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.
|
||||
:::
|
||||
</Config>
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: VLC
|
||||
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/vlc.json.example';
|
||||
|
||||
MS communicates with [VLC](https://www.videolan.org/vlc/) using the [Web (`http`) interface module](https://wiki.videolan.org/VLC_HTTP_requests/)
|
||||
|
||||
#### Enable HTTP Interface
|
||||
|
||||
Open VLC:
|
||||
|
||||
* **Tools** Menu -> Preferences
|
||||
* In the bottom left change **Show Settings** from `Simple` to `All`
|
||||
* In the updated window select `Interface -> Main interfaces`
|
||||
* In the **Extra interface modules** section enable **Web** and verify that `http` is shown in the textbox
|
||||
* Select `Interface -> Main interfaces -> Lua`
|
||||
* In Lua HTTP -> Password -> set a password in this box
|
||||
* Click **Save** at the bottom to persist your changes
|
||||
* Restart VLC
|
||||
* Verify the HTTP interface is accessible by navigating to `http://localhost:8080` in your browser
|
||||
|
||||
If the interface is accessible, after typing in your password, VLC is now ready to be used by MS.
|
||||
|
||||
##### Setting VLC Interface and Port
|
||||
|
||||
These can be set by starting VLC from command line with specific options:
|
||||
|
||||
* `vlc --http-host yourHostIp:yourPort` or
|
||||
* `vlc --http-port yourPort` or
|
||||
* modify the configuration file for VLC by editing keys with the same values as from the command line (`http-host` and `http-port`)
|
||||
|
||||
|
||||
#### Filename Parsing
|
||||
|
||||
If the file being played in VLC does not have ID3 tags/metadata information multi-scrobbler can attempt to extract artist, title, and album information from the raw filename using regular expressions. Mutli-scrobbler will only do this if there is **no other information** reported by VLC about these fields.
|
||||
|
||||
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.
|
||||
|
||||
Example usage in [a file-based config:](/configuration?configType=file#configuration-types)
|
||||
|
||||
```json5 title="config/vlc.json" {10-15}
|
||||
[
|
||||
{
|
||||
"name": "MyVlc",
|
||||
"enable": true,
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"filenamePatterns": [
|
||||
// Extracts artist and title from filenames that look like:
|
||||
// My Artist - My Cool title.mp4
|
||||
"/^\\s*(?<artist>.+?) - (?<title>.+?)\\.\\w+$/i"
|
||||
],
|
||||
// logs to DEBUG when MS tries to extract data from a filename
|
||||
"logFilenamePatterns": true,
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 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:
|
||||
|
||||
```json5 title="config/vlc.json" {9-9}
|
||||
[
|
||||
{
|
||||
"name": "MyVlc",
|
||||
"enable": true,
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"dumpVlcMetadata": true
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="VLCSourceConfig" fileContent={JsonConfig} name="vlc">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|------------|------------------|-------------|
|
||||
| `VLC_URL` | No | `localhost:8080` | |
|
||||
| `VLC_PASSWORD` | Yes | | |
|
||||
</Config>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Webscrobbler
|
||||
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/webscrobbler.json.example';
|
||||
|
||||
After installing the [Webscrobbler extension](https://web-scrobbler.com/) open the preferences/settings for it:
|
||||
|
||||
* Under **Accounts**
|
||||
* **Add Webhook**
|
||||
* API URL: `[YourMultiScrobblerInstance]/api/webscrobbler` EX `http://localhost:9078/api/webscrobbler`
|
||||
* Application name: `(whatever you want)`
|
||||
|
||||
Reload the extension after adding the webhook.
|
||||
|
||||
:::note
|
||||
|
||||
* **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
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
||||
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).
|
||||
|
||||
:::
|
||||
|
||||
### Multiple Users
|
||||
|
||||
If you would like use multiple WebScrobbler sources they can be matched using a **slug** at the end of the **API URL.** This requires using [a file-based config.](/configuration?configType=file#configuration-types)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json title="webscrobbler.json"
|
||||
[
|
||||
{
|
||||
"name": "aUserWS",
|
||||
"clients": [
|
||||
"client1Maloja"
|
||||
],
|
||||
"data": {
|
||||
"slug": "usera"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bUserWS",
|
||||
"clients": [
|
||||
"client2Maloja"
|
||||
],
|
||||
"data": {
|
||||
"slug": "userb"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
* To use `aUserWS` source set **API URL** to `http://localhost:9078/api/webscrobbler/usera`
|
||||
* To use `bUserWS` source set **API URL** to `http://localhost:9078/api/webscrobbler/userb`
|
||||
|
||||
:::tip
|
||||
|
||||
`http://localhost:9078/api/webscrobbler` is matched with the first source that _that does not have a slug defined._
|
||||
|
||||
:::
|
||||
|
||||
</details>
|
||||
|
||||
### Connectors Black/Whitelist
|
||||
|
||||
MS can be configured to only scrobble, or NOT scrobble, from some WS connectors. Use the name of the website from the [supported websites](https://web-scrobbler.com/) or from the **Connectors** tab in the extension.
|
||||
|
||||
:::note
|
||||
|
||||
This affects **only** MS's behavior and does not affect the general connector behavior you have configured within the WebScrobbler extension.
|
||||
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
<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 |
|
||||
| WS_WHITELIST | No | | Only scrobble from these WebScrobbler Connectors. Comma-delimited list |
|
||||
| WS_BLACKLIST | No | | Do not scrobble from these WebScrobbler Connectors. Comma-delimited list |
|
||||
</Config>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Yamaha MusicCast
|
||||
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/musiccast.json.example';
|
||||
|
||||
Monitor [MusicCast](https://usa.yamaha.com/products/contents/audio_visual/musiccast/index.html) device/receivers for music played on Network/USB/CD inputs.
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Musiccast may not correctly report track length. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](/configuration/sources#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
|
||||
|
||||
:::
|
||||
|
||||
##### URL
|
||||
|
||||
If only a domain/IP:PORT is provided to multi-scrobbler it will automatically append the default Musiccast API path. If your Musiccast device is located behind a custom URL (or reverse proxy) you should explicitly provide the base path to use.
|
||||
|
||||
EX
|
||||
|
||||
| `url` | Transformed URL |
|
||||
| --------------------------------------- | :---------------------------------------------- |
|
||||
| `http://192.168.0.101` | `http://192.168.0.101/YamahaExtendedControl/v1` |
|
||||
| `http://mydomain.com/reverseProxy/cast` | `http://mydomain.com/reverseProxy/cast` |
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MusicCastSourceConfig" fileContent={JsonConfig} name="musiccast">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | :----------------------- |
|
||||
| `MCAST_URL` | Yes | | The Musiccast device URL |
|
||||
</Config>
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
title: Youtube Music
|
||||
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/ytmusic.json.example';
|
||||
|
||||
:::warning
|
||||
|
||||
Communication with YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
|
||||
|
||||
:::
|
||||
|
||||
:::tip[Scrobble Troubleshooting]
|
||||
|
||||
Due to monitoring being unofficial, listening history from YTM can be inconsistent and can cause missed scrobbles.
|
||||
|
||||
[**See the FAQ**](/FAQ.md#youtube-music-misses-or-duplicates-scrobbles) for a detailed explanation, how to see more details about MS's detection of tracks, and how to properly report an issue.
|
||||
|
||||
:::
|
||||
|
||||
#### Authentication {#ytm-auth}
|
||||
|
||||
Only one of these methods needs to be used.
|
||||
|
||||
<Tabs groupId="ytmAuth" queryString>
|
||||
<TabItem value="cookie" label="Cookies">
|
||||
|
||||
Use instructions from
|
||||
|
||||
* https://github.com/patrickkfkan/Volumio-YouTube.js/wiki/How-to-obtain-Cookie or
|
||||
* https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html#copy-authentication-headers
|
||||
|
||||
to get the **Cookie** value from a browser.
|
||||
|
||||
It is highly recommended to [get the cookie from an Incognito/Private Session](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2504032666) to limit the chance the session is invalidated from normal browsing.
|
||||
|
||||
Add the cookie to your `ytmusic.json` config in `data` or as an ENV:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"enable": true,
|
||||
"name": "MyYTM",
|
||||
"data": {
|
||||
"cookie": "__Secure-1PSIDTS=sidts-CjEB3EgAEvCd-......"
|
||||
},
|
||||
"options": {
|
||||
"logAuthUpdateChanges": true,
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If MS gives you authentication errors (session invalidated) at some point in the future follow the same instructions to get new cookies.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="oauth" label="OAuth Client">
|
||||
:::warning
|
||||
|
||||
As of Sept 25' OAuth clients no longer seem to have permission to access the YTM service, as reported by the upstream YTM library developer and [several other users.](https://github.com/FoxxMD/multi-scrobbler/issues/345#issuecomment-3258518769)
|
||||
|
||||
If you have existing, working OAuth credentials keep using them but there is no gaurantee they will continue to work.
|
||||
|
||||
If you are setting up the YTM Source for the first time you should first try to use [Cookies](./?ytmAuth=cookie#ytm-auth).
|
||||
|
||||
:::
|
||||
|
||||
[Based on the instructions from here...](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2479689924)
|
||||
|
||||
* Login to [Google Cloud console](https://console.cloud.google.com/) (create an account, if necessary)
|
||||
* [Create a new project](https://console.cloud.google.com/projectcreate)
|
||||
* Go to APIs and services.
|
||||
* Configure the OAuth consent screen
|
||||
* Use the old experience if possible
|
||||
* If new is unavoidable then do not fill out any branding and under Authorized Domains you can delete the empty one (in order to save)
|
||||
* Add yourself as an authorized user
|
||||
* Navigate to Credentials
|
||||
* Create Credentials -> choose "OAuth client ID"
|
||||
* Application Type is **Web Application**
|
||||
* **Name** is whatever you want, leave Authorization Javascript origins blank
|
||||
* Authorized redirect URIs
|
||||
* This must be **exactly** the same as what is displayed in MS! For now leave it blank so we can generate it from MS first
|
||||
* Create
|
||||
* In the newly created client popup save the **Client ID** and **Client Secret**, then copy them into `ytmusic.json` or appropriate ENVs:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"enable": true,
|
||||
"name": "MyYTM",
|
||||
"data": {
|
||||
"clientId": "8910....6jqupl.apps.googleusercontent.com",
|
||||
"clientSecret": "GOCSPX-WGXL6BSuQ343..."
|
||||
},
|
||||
"options": {
|
||||
"logAuthUpdateChanges": true,
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now, start MS and during the YTMusic startup it will log something like this:
|
||||
|
||||
```
|
||||
Will use custom OAuth Client:
|
||||
Client ID: ...
|
||||
Client Secret: ...
|
||||
Redirect URI: http://localhost:9078/api/ytmusic/callback?name=MyYTM
|
||||
```
|
||||
|
||||
If the beginning of the Redirect URI (before `api`) is EXACTLY how you would reach the MS dashboard from your browser (EX `http://localhost:9078`) then edit your google oauth client section for `Authorized redirect URIs` and add the URL MS has displayed.
|
||||
|
||||
If it is NOT EXACTLY the same you either need to set MS's [base url](https://foxxmd.github.io/multi-scrobbler/docs/configuration/#base-url) or you can provide your own (Custom) Redirect URI for MS to use by setting it in `ytmusic.json` or ENV.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Using a Custom Redirect URI</summary>
|
||||
|
||||
The three parts of the URL that must be the same:
|
||||
|
||||
* it must start with `api` (after domain or subdirectory IE `my.domain.tld/api...` or `whatever.tld/subDir/api...`
|
||||
* it must end in `ytmusic/callback`
|
||||
* It must include `name=[NameOfSource]` in the query string
|
||||
|
||||
Remember to add your custom URL to the `Authorized redirect URIs` section in the google oauth client!
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"enable": true,
|
||||
"name": "MyYTM",
|
||||
"data": {
|
||||
"clientId": "8910....6jqupl.apps.googleusercontent.com",
|
||||
"clientSecret": "GOCSPX-WGXL6BSuQ343...",
|
||||
"redirectUri": "http://my.custom.domain/api/ytmusic/callback?name=MyYTM"
|
||||
},
|
||||
"options": {
|
||||
"logAuthUpdateChanges": true,
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
AFTER changing the Authorized redirect URIs on Google Cloud console you may need to wait a few minutes for it to take affect. Then restart MS. From the dashboard click `(Re)authenticate` on the YTmusic source card and follow the auth flow:
|
||||
|
||||
* On the screen about "testing" make sure you hit **Continue** (not Back To Safety)
|
||||
* Make sure to select ALL scopes/permissions/grants it asks you about
|
||||
* `Select what [YourAppName] can access` -> Select all
|
||||
|
||||
Once the flow is finished MS will get the credentials and start polling automatically. You should not need to re-authenticate again after restarting MS as it saves the credentials to the `/config` folder.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="ytt" label="YoutubeTV">
|
||||
|
||||
:::warning
|
||||
|
||||
Using the built-in YoutubeTV authentication is unlikely to work due to [Google restricting what permissions TV clients can have](https://github.com/yt-dlp/yt-dlp/issues/11462#issuecomment-2471703090). This authentication method should not be used.
|
||||
|
||||
:::
|
||||
|
||||
To authenticate start multi-scrobbler with an empty YT Music configuration. An authentication URL/code will be logged in additon to being available from the dashboard.
|
||||
|
||||
```
|
||||
ERROR: Sign in with the code 'CLV-KFA-BVKY' using the authentication link on the dashboard or https://www.google.com/device
|
||||
```
|
||||
|
||||
Visit the authentication URL and enter the code that was provided (also available on the dashboard). After completing the setup flow MS will log `Auth success` and the YT Music dashboard card will display as **Idle** after refreshing. Click the **Start** link to begin monitoring.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="YTMusicSourceConfig" fileContent={JsonConfig} name="ytmusic">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|-----------------------------------------------|
|
||||
| YTM_COOKIE | No | | Value for Cookie Authentication |
|
||||
| YTM_CLIENT_ID | No | | Client ID for OAuth Athentication |
|
||||
| YTM_CLIENT_SECRET | No | | Client Secret for OAuth Athentication |
|
||||
| YTM_REDIRECT_URI | No | | A custom redirect URI for OAuth Athentication |
|
||||
| YTM_LOG_DIFF | No | false | Log YTM history changes |
|
||||
</Config>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
---
|
||||
title: Musicbrainz Stage
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
The **Musicbrainz** [Stage](/configuration/transforms#stage) matches your Play data with the [MusicBrainz](https://musicbrainz.org/) database. If the match score is high enough then Multi-Scrobbler uses the match to correct and fill-in missing information in your Play data.
|
||||
|
||||
**This Stage is useful for standardizing your Scrobble's Play data, regardless of the Source it is coming from.**
|
||||
|
||||
Even if you have have strong opinions about the way your existing music is already tagged/organized you should consider adding the Musicbrainz Stage to your Sources, if only to benefit from adding [MBIDs to scrobbled metadata.](#using-partial-match)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Why Should I Prefer MusicBrainz?</summary>
|
||||
|
||||
<h3>More Than Mainstream Data</h3>
|
||||
|
||||
The [MusicBrainz database contains](https://musicbrainz.org/statistics)
|
||||
|
||||
* 37 million+ tracks
|
||||
* 5 million+ albums
|
||||
* 2.7 million+ artists
|
||||
|
||||
in over 200 languages, contributed by 10,000+ editors each year with many more users voting on edits.
|
||||
|
||||
The database covers a much wider range of music than most commercial services since it isn't restricted by licensing or incentivized to prioritize mainstream music due to business interests. You are much more likely to find data for obscure, indie, and international artists.
|
||||
|
||||
<h3>As The Artist Intended</h3>
|
||||
|
||||
While some large services (Spotify) may be more consistent in data structure, the MusicBrainz equivalent data is likely to be more true to the way the Artist originally intended since individuals can devote effort to individual artists. A fan of an artist is much more likely to "get it right" for their favorite band than a corporate business trying to do the least amount of work for the largest result.
|
||||
|
||||
</details>
|
||||
|
||||
:::tip
|
||||
|
||||
If you are using [ENV Config](/configuration?config-type=env#configuration-types) for multi-scrobbler and just want a quick and easy setup, skip to [**ENV Configuration**](#env-configuration).
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
Set up [Valkey Caching](/configuration?cachedThings=metadata#caching) to cache Musicbrainz API calls for faster processing.
|
||||
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
### API Setup
|
||||
|
||||
To avoid rate limiting, MusicBrainz requires API users to identify themself using a [User-Agent that contains an email or URL.](https://wiki.musicbrainz.org/MusicBrainz_API/Rate_Limiting#How_can_I_be_a_good_citizen_and_be_smart_about_using_the_Web_Service?) You must set this in the `data` for your [stage configuration](http://localhost:3000/docs/configuration/transforms/#configuring-stages) in your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"data": {
|
||||
"apis": [
|
||||
{
|
||||
"contact": "contact@mydomain.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Additional Musicbrainz Servers</summary>
|
||||
|
||||
Other, or additional, Musicbrainz Servers/Mirrors can be added to the API configuration. If more than one server is defined then multi-scrobbler will round-robin load balance API calls.
|
||||
|
||||
Use `url` to define the base URL of the Musicbrainz server to use. If `url` is not defined multi-scrobbler assumes it is the primary Musicbrainz server, `https://musicbrainz.org`.
|
||||
|
||||
Example of multiple servers:
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"data": {
|
||||
"apis": [
|
||||
{
|
||||
"contact": "contact@mydomain.com"
|
||||
// uses default Musicbrainz server https://musicbrainz.org
|
||||
},
|
||||
// additional server
|
||||
{
|
||||
"contact": "contact@mydomain.com",
|
||||
"url": "https://my.mb.mirror.domain.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Stage Configuration
|
||||
|
||||
All of the properties found in [**Matching with Musicbrainz**](#matching-with-musicbrainz) section are configured in [Stage Configuration](/configuration/transforms#configuring-stages) as `defaults`.
|
||||
|
||||
Example:
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"data": {
|
||||
"apis": [
|
||||
{
|
||||
"contact": "contact@mydomain.com"
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
"releaseStatusPriority": ["official"],
|
||||
"fallbackArtistSearch": "native",
|
||||
"releaseAllowEmpty": true
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 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-moditication):
|
||||
|
||||
```json5
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB"
|
||||
// ...
|
||||
"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
|
||||
"meta": true // adds MusicBrainz MBIDs to scrobble data
|
||||
}
|
||||
```
|
||||
|
||||
## ENV Configuration
|
||||
|
||||
The general configuration shown above can also be configured from a selection of *presets* using [ENV Config](/configuration?configType=env#configuration-types) for individual Sources/Clients.
|
||||
|
||||
You must **always** include the `MB_CONTACT` ENV to [identify your application](#api-setup):
|
||||
|
||||
```ini
|
||||
MB_CONTACT=contact@mydomain.com
|
||||
```
|
||||
|
||||
To configure [stage defaults](#stage-configuration) use `MB_PRESETS` with a comma-delimited list of presets you wish to apply. More than one preset can be applied, in which case they combine. You must choose at least one of the presets below:
|
||||
|
||||
* `default` - Applies no defaults. This is the same as using no options from [Matching With Musicbrainz](#matching-with-musicbrainz)
|
||||
* `sensible` - Applies the [Sensible Default](#sensible-default) configuration for sorting releases
|
||||
* `native` - Applies `fallbackArtistSearch: native` for [Artist Extraction](#artist-extraction) if the first search fails to find matches
|
||||
* `aggressive` - Applies [Free Text Search](#free-text) `fallbackFreeTextSearch: true` if all other searches fail
|
||||
|
||||
Finally, use ENV `*_TRANSFORMS=musicbrainz` 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.
|
||||
|
||||
:::tip
|
||||
|
||||
Enhancing a scrobble with the `preTransform` hook for a Source means that all Clients that recieve the scrobble get the enhanced version. You only need to apply the transform once, for a Source, to use it everywhere for downstream Clients.
|
||||
|
||||
:::
|
||||
|
||||
<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:
|
||||
- MB_CONTACT=contact@mydomain.com
|
||||
- MB_PRESETS=sensible,native # creates sensible defaults with native fallback searching
|
||||
|
||||
- JELLYFIN_URL=192.168.0.110:8096
|
||||
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b
|
||||
- JELLYFIN_USER=MyUser
|
||||
- JELLYFIN_TRANSFORMS=musicbrainz # applies musicbrainz 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>
|
||||
|
||||
## Matching with Musicbrainz
|
||||
|
||||
:::note
|
||||
|
||||
**All properties found in this section are optional.**
|
||||
|
||||
If you just want a sensible default for configuraion refer to the [Best Practices](#best-practices) section.
|
||||
|
||||
:::
|
||||
|
||||
Matching your Scrobble's Play data with a result from Musicbrainz is comprised of two steps:
|
||||
|
||||
* [**Searching**](#searching) Musicbrainz using parts of your Scrobble as queries
|
||||
* [**Refining**](#refining) matched results to select the desired/allowed match
|
||||
|
||||
Both steps have separate configuration.
|
||||
|
||||
### Searching
|
||||
|
||||
The first step is making [search queries to the Musicbrainz database](https://musicbrainz.org/doc/MusicBrainz_API/Search) to try to get potential candidates. Multi-scrobbler uses the **title, artist(s), and album** from your Scrobble data to query for matches.
|
||||
|
||||
Musicbrainz agressively normalizes these fields in its database which means that if your Scrobble data contains major innaccuracies or phrases fields in a non-normal way, it's possible Musicbrainz won't find any matches. This is a more common occurrence when trying to match Scrobble data from Sources that don't support multiple artist data, like [Subsonic](/configuration/sources/subsonic) and [Last.fm](/configuration/sources/lastfm).
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples of Bad Data</summary>
|
||||
|
||||
Last.fm Scrobble returns an Artist in the title and combines two artists into one string:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Endless Possibility (feat. Wheatus)",
|
||||
"artists": ["Bowling For Soup & Punk Rock Factory"]
|
||||
}
|
||||
```
|
||||
|
||||
The corresponding [Musicbrainz Recording](https://musicbrainz.org/release/85bf284b-8b39-414e-8ad9-61f593072588) has all artists separated and no artist in the title:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Endless Possibility",
|
||||
"artists": ["Bowling For Soup", "Punk Rock Factory", "Wheatus"]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**If a normal search using all the fields mentioned above does not return any matches Multi-scrobbler can try additional searches with modified queries:**
|
||||
|
||||
#### Album Only
|
||||
|
||||
If your Scrobble data contains a title, artist(s), and an album (all three fields) then Multi-scrobbler will **automatically** retry the search using only title and album.
|
||||
|
||||
#### Artist Extraction
|
||||
|
||||
If...
|
||||
|
||||
* [Album Only](#album-only) returns no results or did not run due to missing an album
|
||||
* Your scrobble data contains only **one** artist string
|
||||
* `fallbackArtistSearch` is set in [Stage Configuration](/configuration/transforms#configuring-stages)
|
||||
|
||||
Then Multi-scrobbler will attempt to extract multiple artists from your artist and track string. **This is not automatic. You must set `fallbackArtistSearch` to enable this additional search.**
|
||||
|
||||
`fallbackArtistSearch` can be set to **Native** or **Naive** mode:
|
||||
|
||||
<Tabs groupId="fallbackArtist" queryString>
|
||||
<TabItem value="native" label="Native (Recommended)">
|
||||
|
||||
**Native** (`native`) mode uses an aggressive configuration of the [Native Stage](/configuration/transforms/native) to extract artists using common delimiters and common "joined" artist patterns from the artist and title string of your Scrobble data.
|
||||
|
||||
|
||||
:::tip
|
||||
|
||||
If you already have a Native Stage configured you should use that instead, running it before the Musicbrainz stage.
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Native Mode Example</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Endless Possibility (feat. Wheatus)",
|
||||
"artists": ["Bowling For Soup & Punk Rock Factory, My Cool Band"]
|
||||
}
|
||||
```
|
||||
|
||||
* Extracts `Bowling For Soup` `Punk Rock Factory` `My Cool Band` from artist string
|
||||
* Extracts `Wheatus` from title string
|
||||
* Removes `(feat. Wheatus)` from title string because it found an artist there
|
||||
|
||||
Resulting data used for Musicbrainz search:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Endless Possibility",
|
||||
"artists": ["Bowling For Soup", "Punk Rock Factory", "My Cool Band", "Wheatus"]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
[Stage Configuration](/configuration/transforms#configuring-stages) example:
|
||||
|
||||
```json5
|
||||
// ...
|
||||
"defaults": {
|
||||
// ...
|
||||
"fallbackArtistSearch": "native"
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="naive" label="Naive">
|
||||
|
||||
**Naive** (`naive`) mode looks for the first found common delimiter in the artist string. If it finds one then it uses the preceding value as the only artist in the Musicbrainz search. It does not try to extract additional artists from the artist string, or extract anything from the title string.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Naive Mode Example</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Endless Possibility (feat. Wheatus)",
|
||||
"artists": ["Bowling For Soup, Punk Rock Factory & Wheatus"]
|
||||
}
|
||||
```
|
||||
|
||||
* Finds `&` as first common delimiter, extracts "Bowling For Soup"
|
||||
|
||||
Resulting data used for Musicbrainz search:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Endless Possibility (feat. Wheatus)",
|
||||
"artists": ["Bowling For Soup"]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
[Stage Configuration](/configuration/transforms#configuring-stages) example:
|
||||
|
||||
```json5
|
||||
// ...
|
||||
"defaults": {
|
||||
// ...
|
||||
"fallbackArtistSearch": "naive"
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
#### Free Text
|
||||
|
||||
If both [Album Only](#album-only) and [Artist Extraction](#artist-extraction) searches fail to return results you can additionally enable **Free Text** search. This will search Musicbrainz for **all** text of your Scrobble data artist/title/album, without constraint. **This is not automatic. You must set `fallbackFreeTextSeach: true` to enable this additional search.**
|
||||
|
||||
:::warning
|
||||
|
||||
Free text search is **unconstrained** which means that Musicbrainz will match text in **any** part of a Recording (artist, release, or recording field), regardless of where it is found. This may lead to results that are unexpected and undesired.
|
||||
|
||||
If you know your Source's Play data is well organized you should not enable this. Free Text search should only be used if:
|
||||
|
||||
* your music is not well organized or
|
||||
* there may be many alternative titles/artists for the music you listen to (such as with [psuedo-releases](https://wiki.musicbrainz.org/Release#Status))
|
||||
* you can tolerate that matches may not be accurate for releases.
|
||||
* Generally, if you keep [score](#score) high then matched releases should *at least* be the right artist or what you would expect within a reason, as a match. The release may not be the one you actually listened to but it would be "close enough".
|
||||
|
||||
:::
|
||||
|
||||
[Stage Configuration](/configuration/transforms#configuring-stages) example:
|
||||
|
||||
```json5
|
||||
// ...
|
||||
"defaults": {
|
||||
// ...
|
||||
"fallbackFreeTextSearch": true
|
||||
}
|
||||
```
|
||||
|
||||
### Refining
|
||||
|
||||
### Score
|
||||
|
||||
Each match returned by MusicBrainz contains a numeric score representing how close it was to the search parameters. Set `score` in configuration to set a minimum score that must be met by matches. Default is `90`.
|
||||
|
||||
```json5
|
||||
{
|
||||
// ...
|
||||
"score": 90 // matches must score 90 or higher to be considered
|
||||
}
|
||||
```
|
||||
|
||||
### Filtering
|
||||
|
||||
There are several attributes associated with the [Release](https://wiki.musicbrainz.org/Release) a [Recording](https://wiki.musicbrainz.org/Recording) (individual Track/song) belongs to that you may be interested in controlling. MS can use these attributes to filter what the final Recording selected to match against your Play data is.
|
||||
|
||||
An easy way to think about this:
|
||||
|
||||
* Do you prefer Albums, Singles, or EPs?
|
||||
* Do you always want to use an official album release, or are bootleg or cancelled albums okay to use?
|
||||
* Should a track that belongs to a Compilation album be allowed to match?
|
||||
* What country do you prefer an album release to be from?
|
||||
|
||||
##### Release Attributes
|
||||
|
||||
These attributes (`attribute_name` in config) are:
|
||||
|
||||
* [Release **Status**](https://wiki.musicbrainz.org/Release#Status) (`releaseStatus`) - Is this release/album/ep official, promotional, bootleg, etc...
|
||||
* [Release Group **Primary Type**](https://wiki.musicbrainz.org/Release_Group/Type#Primary_types) (`releaseGroupPrimaryType`) - Is this release an Album, Single, EP, etc...
|
||||
* [Release Group **Secondary Type**](https://wiki.musicbrainz.org/Release_Group/Type#Secondary_types) (`releaseGroupSecondaryType`) - Is this release a compilation, soundtrack, remix, etc...
|
||||
* [Release **Country**](https://beta.musicbrainz.org/doc/Release/Country) (`releaseCountry`) - What ISO2 country was this released in? US, GB, MX, etc...
|
||||
* MusicBrainz uses the special code `XW` to represent a "Worldwide" release AKA release not made specifically for a country
|
||||
|
||||
Each of the above attributes can be used to **filter** matches using `allow` (explicitly include) or `deny` (exclude) terms. The correct property name for each is like so:
|
||||
|
||||
```
|
||||
[attribute_name]Allow = [...]
|
||||
[attribute_name]Deny
|
||||
|
||||
EX to explicitly allow only release status "official"
|
||||
|
||||
"releaseStatusAllow": ["official"]
|
||||
|
||||
EX to exclude compilations
|
||||
|
||||
"releaseGroupSecondaryTypeDeny": ["compilation"]
|
||||
```
|
||||
|
||||
#### Empty Releases
|
||||
|
||||
Sometimes a Recording may not have any associated Releases. This may be because there is not enough information about the Recording yet, or it was never included on an actual Release.
|
||||
|
||||
You may want to filter the majority of your matches by releases but allow matching a Recording that has no releases to begin with. To allow this set `releaseAllowEmpty` to `true` in configuration:
|
||||
|
||||
```json5
|
||||
// ...
|
||||
"defaults": {
|
||||
// ...
|
||||
"releaseAllowEmpty": true // don't remove a match during filtering just because it has no releases
|
||||
}
|
||||
```
|
||||
|
||||
### Sorting
|
||||
|
||||
Each [Release Attribute](#release-attribute) has one additional property that can be used to **rank** releases based on the order of the values you give it. This is the `priority` property.
|
||||
|
||||
*After* matches have been [filtered](#filtering), the remaining matches will have their releases sorted. Release with an attribute that does not match are **not** removed, but they are sorted lower than releases that do match.
|
||||
|
||||
Sorting can be a good alternative to filtering: with filters there is a possibility your filters may eliminate all matches; if you want to ensure that **some** match will be used then sorting can ensure that the **best choice** out of those given will always be used, without accidentally ending up with **no choice**.
|
||||
|
||||
Sorting can be used instead of filters, or in conjunction with filters, it's your choice.
|
||||
|
||||
In configuration:
|
||||
|
||||
```
|
||||
[attribute_name]Priority = [...]
|
||||
|
||||
EX to prefer "official" releases over everything else
|
||||
|
||||
"releaseStatusPriority": ["official"]
|
||||
|
||||
EX to prefer albums, then singles, over everything else
|
||||
|
||||
"releaseGroupPrimaryTypePriority": ["album", "single"]
|
||||
```
|
||||
|
||||
If a rule is not present then multi-scrobbler defaults it to `true`.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Sensible Default
|
||||
|
||||
Generally, the Musicbrainz Stage can be used without any of the [optional configuration](#configuration) and you should still see good results from matches. The top [scored](#score) match is, anecdotally, good enough for correcting and filling in surface-level play data like Title and Artist names.
|
||||
|
||||
For a more opinionated match that will mirror what you would expect from data from large music services:
|
||||
|
||||
```json5
|
||||
{
|
||||
// use official release over anything else
|
||||
"releaseStatusPriority": ["official"],
|
||||
// prefer album, then single, then ep
|
||||
"releaseGroupPrimaryTypePriority": ["album", "single", "ep"],
|
||||
// prefer worldwide release
|
||||
"releaseCountryPriority": ["XW"]
|
||||
}
|
||||
```
|
||||
|
||||
Consider adding [Artist Extraction](./?fallbackArtist=native#artist-extraction) in **Native Mode** if any of your Sources do not support multiple artists or your music collection is not tagged well.
|
||||
|
||||
```json5
|
||||
{
|
||||
// add the line below to the sensible defaults above
|
||||
// (don't forget commas)
|
||||
"fallbackArtistSearch": "native"
|
||||
}
|
||||
```
|
||||
|
||||
### Filter Considerations
|
||||
|
||||
When using your own filters consider:
|
||||
|
||||
* Prefer `deny` over `allow`
|
||||
* Releases come in all kinds of formats. Since `allow` is explicit you may filter out your desired match without realizing it (correct data except for release type). Or the Musicbrainz data for a higher scored match may be appropriate but you did not include it, exhaustively.
|
||||
* Prefer [Sorting](#sorting) over [Filtering](#filtering)
|
||||
* Sorting does not eliminate any matches. It is, generally, better to get **some** match than it is to have your Play data completely uncorrected because filtering eliminated all matches
|
||||
|
||||
### Using Partial Match
|
||||
|
||||
Use [Rules](#rules) to apply MusicBrainz match data selectively.
|
||||
|
||||
If you know that your music collection is well organized and you do not want to change the artists/title/album etc... sent to a [Client](/configuration/clients), you can still benefit from matches by only applying MBIDs using `meta` so that any Client that supports Musicbrainz data ([Koito](/configuration/clients/koito), [Tealfm](/configuration/clients/tealfm), [Listenbrainz](/configuration/clients/listenbrainz), [Rocksky](/configuration/clients/rocksky)) can still get that data.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"title": false,
|
||||
"artists": false,
|
||||
"album": false,
|
||||
"albumArtists": false,
|
||||
"meta": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Logging
|
||||
|
||||
If
|
||||
|
||||
* Musicbrainz is not returning matches
|
||||
* Multi-scrobbler is using the wrong match
|
||||
* or the resulting enhanced Scrobble is not what you expected
|
||||
|
||||
Enable logging by turning on [**Debug Mode**](/configuration#debug-mode) to help diagnose any issues with the Musicbrainz API and Scrobble enhancement. **Before creating an issue** please enable logging and include any logs with your issue as this is needed to debug.
|
||||
|
||||
If you have multiple Modification Stages and need to see the diff for your Play between each Stage, enable `"log": "all"` in the individual [Modification Stage](http://localhost:3000/docs/configuration/transforms/musicbrainz/?fallbackArtist=native#artist-extraction).
|
||||
|
||||
<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": {
|
||||
// highlight-start
|
||||
"log": "all",
|
||||
// highlight-end
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"data": {
|
||||
"apis": [
|
||||
{
|
||||
"contact": "contact@mydomain.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
### Sensible Default
|
||||
|
||||
Using the config shown in [Sensible Default](#sensible-default) with File/AIO config, instead of [ENV Config](#env-configuration).
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"data": {
|
||||
"apis": [
|
||||
{
|
||||
"contact": "contact@mydomain.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"defaults": {
|
||||
"releaseStatusPriority": ["official"],
|
||||
"releaseGroupPrimaryTypePriority": ["album", "single", "ep"],
|
||||
"releaseCountryPriority": ["XW"],
|
||||
"fallbackArtistSearch": "native"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In a [Subsonic](/configuration/sources/subsonic) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
### Add Metadata Only (No Scrobble Modification)
|
||||
|
||||
If you do not want your scrobble data to be modified (artists/album/title) but still want to benefit from associating the scrobble with a Musicbrainz match (for Scrobble [Clients](/configuration/clients) like [Tealfm](/configuration/clients/tealfm), [Rocksky](/configuration/clients/rocksky), and [Koito](/configuration/clients/koito)), then use [Partial Match](#using-partial-match) rules to specify only `meta` to be updated.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Your [AIO Config](/configuration?configType=aio#configuration-types):
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
// ...
|
||||
"transformers": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"data": {
|
||||
"apis": [
|
||||
{
|
||||
"contact": "contact@mydomain.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"defaults": {
|
||||
// maybe something like Sensible Default?
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In a [Jellyfin](/configuration/clients/jellyfin) [File Config](/configuration?configType=file#configuration-types):
|
||||
|
||||
```json5 title="subsonic.json"
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"data": { /* ... */},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
{
|
||||
"type": "musicbrainz",
|
||||
"name": "MyMB",
|
||||
"title": false,
|
||||
"artists": false,
|
||||
"album": false,
|
||||
"albumArtists": false,
|
||||
"meta": true // only update scrobble metadata with MBIDs
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
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-moditication):
|
||||
|
||||
```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#modification-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.
|
||||
|
||||
:::tip
|
||||
|
||||
Enhancing a scrobble with the `preTransform` hook for a Source means that all Clients that recieve the scrobble get the enhanced version. You only need to apply the transform once, for a Source, to use it everywhere for downstream Clients.
|
||||
|
||||
:::
|
||||
|
||||
<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,484 @@
|
||||
---
|
||||
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
|
||||
|
||||
## 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 (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.
|
||||
|
||||
:::
|
||||
|
||||
:::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"
|
||||
// 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.
|
||||
|
||||
:::
|
||||
|
||||
## 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.
|
||||
|
||||
```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 the output 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
|
||||
* `"log": "all"` => Output original play + final transformed output of **each** Stage in the array
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "myThing",
|
||||
"data": {/*...*/},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {/*...*/},
|
||||
"log": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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-stage) 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#configuration) then multi-scrobbler automatically builds an empty one for you.
|
||||
|
||||
This can be used by omitting the `name` in your [modification Stage](/configuration/transforms#modification-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-expressions):
|
||||
|
||||
```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>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"label": "Development",
|
||||
"position": 5,
|
||||
"position": 6,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Developing for Multi-Scrobbler and tutorials"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
slug: /
|
||||
title: Overview
|
||||
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/).
|
||||
|
||||
* Supports scrobbling from many [**Sources**](/configuration/sources)
|
||||
* [Azuracast](/configuration/sources/azuracast)
|
||||
* [Deezer](/configuration/sources/deezer)
|
||||
* [Google Cast (Chromecast)](/configuration/sources/google-cast)
|
||||
* [Icecast](/configuration/sources/icecast)
|
||||
* [Jellyfin](/configuration/sources/jellyfin)
|
||||
* [JRiver](/configuration/sources/jriver)
|
||||
* [Kodi](/configuration/sources/kodi)
|
||||
* [Koito](/configuration/sources/koito-source)
|
||||
* [Last.fm](/configuration/sources/lastfm-source)
|
||||
* [Last.fm (Endpoint)](/configuration/sources/lastfm-endpoint)
|
||||
* [ListenBrainz](/configuration/sources/listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint)
|
||||
* [Maloja](/configuration/sources/maloja)
|
||||
* [Mopidy](/configuration/sources/mopidy)
|
||||
* [MPD (Music Player Daemon)](/configuration/sources/mpd)
|
||||
* [MPRIS (Linux Desktop)](/configuration/sources/mpris)
|
||||
* [Musikcube](/configuration/sources/musikcube)
|
||||
* [Plex](/configuration/sources/plex)
|
||||
* [Rocksky](/configuration/sources/rocksky-source)
|
||||
* [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)
|
||||
* [WebScrobbler](/configuration/sources/webscrobbler)
|
||||
* [VLC](/configuration/sources/vlc)
|
||||
* [Yamaha MusicCast](/configuration/sources/yamaha-musiccast)
|
||||
* [Youtube Music](/configuration/sources/youtube-music)
|
||||
* Supports scrobbling to many [**Clients**](/configuration/clients)
|
||||
* [Koito](/configuration/clients/koito)
|
||||
* [Last.fm](/configuration/clients/lastfm)
|
||||
* [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!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
* 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)
|
||||
* Install using [Docker images for x86/ARM](/installation#docker) or [locally with NodeJS](/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](/quickstart)
|
||||
|
||||
<img src={require('/img/status-ui.png').default} width="800"/>
|
||||
|
||||
**Why should I use this over a browser extension and/or mobile app scrobbler?**
|
||||
|
||||
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
|
||||
* **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...
|
||||
|
||||
Source configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Source.
|
||||
* An optional list of Client names that the Source should scrobble to. If omitted the Source also scrobbles to all configured Clients.
|
||||
|
||||
### 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**...
|
||||
|
||||
Client configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Client.
|
||||
|
||||
## Quick Start
|
||||
|
||||
[See the **Quick Start Guide**](/quickstart)
|
||||
|
||||
## Installation
|
||||
|
||||
[See the **Installation** documentation](/installation)
|
||||
|
||||
## Configuration
|
||||
|
||||
[See the **Configuration** documentation](/configuration)
|
||||
|
||||
## Usage
|
||||
|
||||
A status page with statistics, recent logs, and some runtime configuration options can be found at
|
||||
|
||||
```
|
||||
http://localhost:9078
|
||||
```
|
||||
Output is also provided to stdout/stderr as well as file if specified in configuration.
|
||||
|
||||
On first startup you may need to authorize Spotify and/or Last.fm by visiting the callback URL (which can also be accessed from the status page). Visit the status page above to find the applicable link to trigger this.
|
||||
|
||||
## Help/FAQ
|
||||
|
||||
Having issues with connections or configuration? Check the [FAQ](/FAQ) before creating an issue!
|
||||
|
||||
## Development
|
||||
|
||||
[Detailed architecture and development guides for Sources/Clients](/development/dev-common)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Installation",
|
||||
"position": 2,
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Way to install Multi-Scrobbler"
|
||||
"type": "doc",
|
||||
"id": "installation/installation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,16 +166,16 @@ To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
:::tip
|
||||
|
||||
See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-compose example
|
||||
See the [**Quick Start Guide**](/quickstart) for another guided docker-compose example
|
||||
|
||||
:::
|
||||
|
||||
The example scenario:
|
||||
|
||||
* [Jellyfin **Source**](../configuration/configuration.mdx#jellyfin)
|
||||
* [Maloja **Client**](../configuration/configuration.mdx#maloja)
|
||||
* [Jellyfin **Source**](/configuration/sources/jellyfin)
|
||||
* [Maloja **Client**](/configuration/sources/maloja)
|
||||
* Serving app on port `9078`
|
||||
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](../configuration/configuration.mdx#base-url)
|
||||
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](/configuration#base-url)
|
||||
* Config/data directory on host machine in a directory next to `docker-compose.yml`
|
||||
* Linux uid/gid is `1000:1000`
|
||||
|
||||
|
||||
+88
-15
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Quickstart'
|
||||
sidebar_position: 1
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
@@ -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/configuration.mdx#spotify) and/or [Jellyfin](./configuration/configuration.mdx#jellyfin) for listening activity
|
||||
* multi-scrobbler scrobbling to [Lastfm](./configuration/configuration.mdx#lastfm) and/or [Maloja](./configuration/configuration.mdx#maloja)
|
||||
* multi-scrobbler monitoring [Spotify](/configuration/sources/spotify), [Jellyfin](/configuration/sources/jellyfin), Navidrome, [Plex](/configuration/sources/plex), and/or [Last.fm](/configurations/sources/lastfm) for listening activity
|
||||
* multi-scrobbler scrobbling to [Lastfm](/configuration/clients/lastfm), [Maloja](/configuration/clients/maloja), and/or [Koito](/configuration/clients/koito)
|
||||
|
||||
:::note
|
||||
|
||||
@@ -42,11 +42,11 @@ services:
|
||||
|
||||
## Setup Sources
|
||||
|
||||
**Sources** are the services multi-scrobbler monitors to look for listening activity.
|
||||
[**Sources**](/configuration/sources) are the services/music players multi-scrobbler monitors to look for listening activity.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="jellyfin" label="Jellyfin">
|
||||
Follow the Jellyfin configuration [instructions for setting up an **API Key**.](./configuration/configuration.mdx#jellyfin)
|
||||
Follow the Jellyfin configuration [instructions for setting up an **API Key**.](/configuration/sources/jellyfin)
|
||||
|
||||
Then, add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
To access your Spotify activity you must [create a Spotify App](https://developer.spotify.com/dashboard) to get a
|
||||
**Client ID/Secret** and set a valid **Redirect URI**.
|
||||
|
||||
Refer to the [Spotify Authentication instructions in the docs.](./configuration/configuration.mdx#authenticate-spotify-with-multi-scrobbler)
|
||||
Refer to the [Spotify Authentication instructions in the docs.](/configuration/sources/spotify#authenticate-spotify-with-multi-scrobbler)
|
||||
|
||||
Assuming you used the **HTTPS** method for your **Redirect URI** from the instructions above...after the application is created add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
@@ -72,11 +72,83 @@ 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.
|
||||
|
||||
Create a new [File](/configuration?configType=file#configuration-types) named `lastfm.json` in the `./config` directory that will be used with your [`docker-compose.yml` stack](#create-docker-compose-file) and copy the below contents, replacing placeholders with the data you got from Last.fm API account creation:
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
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>
|
||||
|
||||
## Setup Clients
|
||||
|
||||
**Clients** are services that store scrobbles. Multi-scrobbler will scrobble all listening activity from the **Sources** you configured to all **Clients** you configure here.
|
||||
[**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>
|
||||
<TabItem value="maloja" label="Maloja">
|
||||
@@ -138,7 +210,7 @@ services:
|
||||
* 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/configuration.mdx#koito)
|
||||
* [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)
|
||||
|
||||
@@ -161,7 +233,7 @@ services:
|
||||
|
||||
:::tip
|
||||
|
||||
If you are running your multi-scrobbler container on a Linux host see [these instructions for setting proper file permissions.](./installation/installation.mdx#linux-host)
|
||||
If you are running your multi-scrobbler container on a Linux host see [these instructions for setting proper file permissions.](/installation#linux-host)
|
||||
|
||||
:::
|
||||
|
||||
@@ -182,10 +254,11 @@ Visit `http://192.168.0.100:9078` to see the dashboard where
|
||||
|
||||
## Next Steps
|
||||
|
||||
* See more advanced docker options as well as other install methods in the [**Installation**](./installation/installation.mdx#docker) docs
|
||||
* Review the [**Configuration**](./configuration/configuration.mdx) docs
|
||||
* See more advanced docker options as well as other install methods in the [**Installation**](/installation#docker) docs
|
||||
* Review the [**Configuration**](/configuration) docs
|
||||
* Learn about how to configure multi-scrobbler using files for more complicated Source/Client scenarios
|
||||
* See all available Sources/Clients alongside configuration examples
|
||||
* Learn how to set up [notification webhooks](./configuration/configuration.mdx#webhook-configurations)
|
||||
* Check out the [kitchensink example](./configuration/kitchensink.md)
|
||||
* Consult the [**FAQ**](./FAQ.md) for solutions to common problems
|
||||
* 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"label": "Updating",
|
||||
"position": 3,
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Way to update Multi-Scrobbler"
|
||||
|
||||
@@ -49,6 +49,7 @@ const config: Config = {
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
routeBasePath: '/',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl:
|
||||
@@ -79,6 +80,7 @@ const config: Config = {
|
||||
// `hashed` is recommended as long-term-cache of index file is possible.
|
||||
hashed: true,
|
||||
indexBlog: false,
|
||||
docsRouteBasePath: "/",
|
||||
// For Docs using Chinese, The `language` is recommended to set to:
|
||||
// ```
|
||||
// language: ["en", "zh"],
|
||||
@@ -88,6 +90,38 @@ const config: Config = {
|
||||
'docusaurus-theme-github-codeblock'
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
'@docusaurus/plugin-client-redirects',
|
||||
{
|
||||
redirects: [
|
||||
// /docs/oldDoc -> /docs/newDoc
|
||||
{
|
||||
to: '/configuration',
|
||||
from: '/docs/configuration',
|
||||
},
|
||||
{
|
||||
to: '/configuration/kitchensink',
|
||||
from: '/docs/configuration/kitchensink',
|
||||
},
|
||||
{
|
||||
to: '/installation',
|
||||
from: '/docs/installation',
|
||||
},
|
||||
{
|
||||
to: '/quickstart',
|
||||
from: '/docs/quickstart',
|
||||
},
|
||||
{
|
||||
to: '/FAQ',
|
||||
from: '/docs/FAQ',
|
||||
},
|
||||
{
|
||||
to: '/',
|
||||
from: '/docs',
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
themeConfig:
|
||||
{
|
||||
@@ -137,15 +171,15 @@ const config: Config = {
|
||||
},
|
||||
{
|
||||
label: 'Installation',
|
||||
to: 'docs/installation',
|
||||
to: '/installation',
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
to: 'docs/configuration',
|
||||
to: '/configuration',
|
||||
},
|
||||
{
|
||||
label: 'Development',
|
||||
to: 'docs/development/dev-common',
|
||||
to: '/development/dev-common',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+25
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.8.1",
|
||||
"@docusaurus/faster": "3.8.1",
|
||||
"@docusaurus/plugin-client-redirects": "^3.8.1",
|
||||
"@docusaurus/preset-classic": "3.8.1",
|
||||
"@easyops-cn/docusaurus-search-local": "0.51.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
@@ -3406,6 +3407,30 @@
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/plugin-client-redirects": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.8.1.tgz",
|
||||
"integrity": "sha512-F+86R7PBn6VNgy/Ux8w3ZRypJGJEzksbejQKlbTC8u6uhBUhfdXWkDp6qdOisIoW0buY5nLqucvZt1zNJzhJhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.8.1",
|
||||
"@docusaurus/logger": "3.8.1",
|
||||
"@docusaurus/utils": "3.8.1",
|
||||
"@docusaurus/utils-common": "3.8.1",
|
||||
"@docusaurus/utils-validation": "3.8.1",
|
||||
"eta": "^2.2.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"tslib": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/plugin-content-blog": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.8.1",
|
||||
"@docusaurus/faster": "3.8.1",
|
||||
"@docusaurus/plugin-client-redirects": "^3.8.1",
|
||||
"@docusaurus/preset-classic": "3.8.1",
|
||||
"@easyops-cn/docusaurus-search-local": "0.51.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
<EnvType>
|
||||
{props.children}
|
||||
</EnvType>
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<FileType>
|
||||
|
||||
<FileExample title={`CONFIG/${props.name}.json`} client={props.client ?? false} data={props.fileContent}/>
|
||||
|
||||
or <SchemaLink lower objectName={props.config}/>
|
||||
</FileType>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<AIOType>
|
||||
|
||||
<AIOExample data={props.fileContent} client={props.client ?? false} name={props.name}/>
|
||||
|
||||
|
||||
or <SchemaLink lower objectName={props.config}/>
|
||||
</AIOType>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -0,0 +1,2 @@
|
||||
[**AIO** Config Type](/configuration?configType=aio#configuration-types)
|
||||
{props.children}
|
||||
@@ -0,0 +1,14 @@
|
||||
import CodeInline from '@theme/CodeInline';
|
||||
|
||||
The [AT Protocol](https://docs.bsky.app/docs/advanced-guides/atproto) is a standard to allow different applications to share and interpret your social data.
|
||||
|
||||
Your data is stored in a personal [repository](https://atproto.com/guides/data-repos) in a [PDS (Personal Data Server)](https://github.com/Bluesky-social/pds), which is data-type agnostic (it's just JSON). Each application defines their own datatypes and pulls data from your repository to render to you, your network, etc...
|
||||
|
||||
Even though the application and the PDS/repository are independent, all of this is done for you automatically when you create a [Bluesky](https://bsky.social/about) account.
|
||||
|
||||
So...
|
||||
|
||||
* multi-scrobbler "scrobbles" your plays to your repository
|
||||
* in a JSON data structure which includes [{props.name}'s data type and structure](https://atproto.com/specs/lexicon)
|
||||
* at a [known location for {props.name}'s data](https://atproto.com/guides/glossary#collection) (<CodeInline>{props.lexicon}</CodeInline>), in a list that is appended to as you continue to scrobble
|
||||
* when accessing {props.name}, or another application that knows how to read it, your data is pulled from the repository to render the app
|
||||
@@ -0,0 +1,3 @@
|
||||
[**ENV** Config Type](/configuration?configType=env#configuration-types)
|
||||
|
||||
{props.children}
|
||||
@@ -0,0 +1,3 @@
|
||||
[**File** Config Type](/configuration?configType=file#configuration-types)
|
||||
|
||||
{props.children}
|
||||
@@ -0,0 +1,27 @@
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
|
||||
A **Source** that monitors active listening uses one of two metrics to determine if a Song is scrobbable based on your listening:
|
||||
|
||||
* **percent** of track listened to (50% or more)
|
||||
* or amount of time (**duration**) track was listened to (4 minutes or more)
|
||||
|
||||
These default values are based on [last.fm's scrobble guidance.](https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble)
|
||||
|
||||
These values can be customized in the `options` of Source's [file or aio config](/configuration#configuration-types):
|
||||
<CodeBlock
|
||||
language="json5"
|
||||
title={`${props.filename}.json`}>
|
||||
{`{
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
// highlight-start
|
||||
"scrobbleThresholds": {
|
||||
"duration": 40, // scrobbable if listened to for 40 seconds or more
|
||||
"percent": 20 // scrobbable if listened to for 20% or more of the track's length
|
||||
}
|
||||
// highlight-end
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
@@ -0,0 +1,22 @@
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
|
||||
This **Source** supports toggling scrobbling on or off manually. This action can be taken on the Web Dashboard by clicking the link next to **Should Scrobble** found in each Source's status card.
|
||||
|
||||
Additionally, the default behavior for scrobbling can be explicitly set by adding the following value to a Source's `options` in [file or aio config](/configuration?configType=file#configuration-types):
|
||||
|
||||
<CodeBlock
|
||||
language="json5"
|
||||
title={`${props.filename}.json`}>
|
||||
{`{
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
// highlight-start
|
||||
// set to true to always scrobble automatically
|
||||
// set to false to never scrobble automatically
|
||||
"systemScrobble": true
|
||||
// highlight-end
|
||||
}
|
||||
}}`}
|
||||
</CodeBlock>
|
||||
@@ -0,0 +1,3 @@
|
||||
This component **recieves data** from outside services, rather than actively contacting the outside service for data itself.
|
||||
|
||||
In order for it to work multi-scrobbler must be accessible, on an open port or through reverse-proxy, to the service that will be sending data to it.
|
||||
@@ -1,126 +0,0 @@
|
||||
---
|
||||
title: Overview
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
<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/).
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Azuracast](docs/configuration#azuracast)
|
||||
* [Deezer](docs/configuration#deezer)
|
||||
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
|
||||
* [Icecast](docs/configuration#icecast)
|
||||
* [Jellyfin](docs/configuration#jellyfin)
|
||||
* [JRiver](docs/configuration#jriver)
|
||||
* [Kodi](docs/configuration#kodi)
|
||||
* [Koito](docs/configuration#koito-source)
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
|
||||
* [Maloja](docs/configuration#maloja-source)
|
||||
* [Mopidy](docs/configuration#mopidy)
|
||||
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
* [Musikcube](docs/configuration#musikcube)
|
||||
* [Plex](docs/configuration#plex) or [~~Tautulli~~](docs/configuration#tautulli)
|
||||
* [Spotify](docs/configuration#spotify)
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [WebScrobbler](docs/configuration#webscrobbler)
|
||||
* [VLC](docs/configuration#vlc)
|
||||
* [Yamaha MusicCast](docs/configuration#yamaha-musiccast)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Koito](docs/configuration#koito)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz)
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
|
||||
* Supports [Now Playing](docs/configuration#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
|
||||
* Graceful network and client failure handling (queued scrobbles that auto-retry)
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Modify data before scrobbling with [regular expression or search patterns](docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker) or [locally with NodeJS](docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](docs/quickstart)
|
||||
|
||||
<img src={require('/img/status-ui.png').default} width="800"/>
|
||||
|
||||
**Why should I use this over a browser extension and/or mobile app scrobbler?**
|
||||
|
||||
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
|
||||
* **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.
|
||||
|
||||
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm-source) or [Listenbrainz as a **Source**](docs/configuration#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.
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
You set up configurations for one or more **Sources** and one or more **Clients**. MS monitors all of your configured **Sources**. When new tracks are played by a Source it grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to scrobble to.
|
||||
|
||||
### Source
|
||||
|
||||
A **Source** is a data source that contains information about tracks you are playing like a music player or platform. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Airsonic**, etc...
|
||||
|
||||
Source configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Source.
|
||||
* An optional list of Client names that the Source should scrobble to. If omitted the Source also scrobbles to all configured Clients.
|
||||
|
||||
### Client
|
||||
|
||||
A **Client** is an application that stores the historical information about what songs you have played (scrobbles). Examples are **Maloja, Last.fm, Listenbrainz**...
|
||||
|
||||
Client configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Client.
|
||||
|
||||
## Quick Start
|
||||
|
||||
[See the **Quick Start Guide**](docs/quickstart)
|
||||
|
||||
## Installation
|
||||
|
||||
[See the **Installation** documentation](docs/installation)
|
||||
|
||||
## Configuration
|
||||
|
||||
[See the **Configuration** documentation](docs/configuration)
|
||||
|
||||
## Usage
|
||||
|
||||
A status page with statistics, recent logs, and some runtime configuration options can be found at
|
||||
|
||||
```
|
||||
http://localhost:9078
|
||||
```
|
||||
Output is also provided to stdout/stderr as well as file if specified in configuration.
|
||||
|
||||
On first startup you may need to authorize Spotify and/or Last.fm by visiting the callback URL (which can also be accessed from the status page). Visit the status page above to find the applicable link to trigger this.
|
||||
|
||||
## Help/FAQ
|
||||
|
||||
Having issues with connections or configuration? Check the [FAQ](docs/FAQ) before creating an issue!
|
||||
|
||||
## Development
|
||||
|
||||
[Detailed architecture and development guides for Sources/Clients](docs/development/dev-common)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
// Import the original mapper
|
||||
import MDXComponents from '@theme-original/MDXComponents';
|
||||
import SchemaLink from "@site/src/components/SchemaLink";
|
||||
import AIOExample from "@site/src/components/AIOExample";
|
||||
import FileExample from "@site/src/components/FileExample";
|
||||
import EnvType from "@site/src/components/snippets/_env-config.mdx";
|
||||
import FileType from "@site/src/components/snippets/_file-config.mdx";
|
||||
import AIOType from "@site/src/components/snippets/_aio-config.mdx";
|
||||
import Config from "@site/src/components/GenericConfiguration.mdx";
|
||||
|
||||
export default {
|
||||
// Re-use the default mapping
|
||||
...MDXComponents,
|
||||
SchemaLink,
|
||||
AIOExample,
|
||||
FileExample,
|
||||
EnvType,
|
||||
FileType,
|
||||
AIOType,
|
||||
Config
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
Sitemap: https://foxxmd.github.io/multi-scrobbler/sitemap.xml
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
File diff suppressed because one or more lines are too long
Generated
+1421
-515
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.11",
|
||||
"version": "0.10.5",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
@@ -43,6 +43,9 @@
|
||||
"homepage": ".",
|
||||
"dependencies": {
|
||||
"@astronautlabs/mdns": "^1.0.7",
|
||||
"@atcute/identity-resolver-node": "^1.0.3",
|
||||
"@atproto/api": "^0.18.0",
|
||||
"@atproto/oauth-client-node": "^0.3.10",
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"@curvenote/ansi-to-react": "^7.0.0",
|
||||
"@donedeal0/superdiff": "^1.1.1",
|
||||
@@ -51,7 +54,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@foxxmd/chromecast-client": "^1.0.4",
|
||||
"@foxxmd/get-version": "^0.0.3",
|
||||
"@foxxmd/logging": "^0.2.3",
|
||||
"@foxxmd/logging": "^0.2.5",
|
||||
"@foxxmd/redact-string": "^0.1.2",
|
||||
"@foxxmd/regex-buddy-core": "^0.1.2",
|
||||
"@foxxmd/string-sameness": "^0.4.0",
|
||||
@@ -62,6 +65,7 @@
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/express-serve-static-core": "^4.19.6",
|
||||
"address": "^1.2.2",
|
||||
"ajv": "^8.12.0",
|
||||
@@ -93,29 +97,32 @@
|
||||
"icecast-metadata-stats": "^0.1.12",
|
||||
"iso-websocket": "^0.3.0",
|
||||
"iti": "^0.7.0",
|
||||
"jest-diff": "^30.2.0",
|
||||
"json5": "^2.2.3",
|
||||
"keyv": "^5.5.0",
|
||||
"kodi-api": "^0.2.1",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"mopidy": "^1.3.0",
|
||||
"mpd-api": "^1.1.2",
|
||||
"musicbrainz-api": "^0.27.0",
|
||||
"nanoid": "^3.3.1",
|
||||
"node-object-hash": "^3.1.1",
|
||||
"normalize-url": "^8.0.1",
|
||||
"ntfy": "^1.7.6",
|
||||
"object-hash": "^3.0.0",
|
||||
"p-event": "^4.2.0",
|
||||
"p-map": "^7.0.4",
|
||||
"passport": "^0.6.0",
|
||||
"passport-deezer": "^0.2.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"postcss": "^8.4.41",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"sass": "^1.70.0",
|
||||
"round-robin-js": "^3.0.10",
|
||||
"sass": "^1.93.3",
|
||||
"spotify-web-api-node": "^5.0.2",
|
||||
"superagent": "^8.0.9",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"tough-cookie": "^5.1.2",
|
||||
"ts-json-schema-generator": "^2.3.0",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
diff --git a/node_modules/musicbrainz-api/lib/http-client.js b/node_modules/musicbrainz-api/lib/http-client.js
|
||||
index a5ca5f7..f693ca3 100644
|
||||
--- a/node_modules/musicbrainz-api/lib/http-client.js
|
||||
+++ b/node_modules/musicbrainz-api/lib/http-client.js
|
||||
@@ -9,6 +9,7 @@ function isConnectionReset(err) {
|
||||
export class HttpClient {
|
||||
constructor(httpOptions) {
|
||||
this.httpOptions = httpOptions;
|
||||
+ this.preRequest = (httpOptions ?? {}).preRequest
|
||||
}
|
||||
get(path, options) {
|
||||
return this._fetch('get', path, options);
|
||||
@@ -29,18 +30,25 @@ export class HttpClient {
|
||||
options = {};
|
||||
let retryLimit = options.retryLimit && options.retryLimit > 1 ? options.retryLimit : 1;
|
||||
const retryTimeout = this.httpOptions.timeout ? this.httpOptions.timeout : 500;
|
||||
- const url = this._buildUrl(path, options.query);
|
||||
+ let url = this._buildUrl(path, options.query);
|
||||
const cookies = await this.getCookies();
|
||||
- const headers = new Headers(options.headers);
|
||||
+ let headers = new Headers(options.headers);
|
||||
headers.set('User-Agent', this.httpOptions.userAgent);
|
||||
if (cookies !== null) {
|
||||
headers.set('Cookie', cookies);
|
||||
}
|
||||
+ if(this.preRequest !== undefined) {
|
||||
+ const [m, u, h] = this.preRequest(method, url, headers);
|
||||
+ url = u;
|
||||
+ headers = h;
|
||||
+ }
|
||||
+ let signal = this.httpOptions.requestTimeout !== undefined ? AbortSignal.timeout(this.httpOptions.requestTimeout) : undefined;
|
||||
while (retryLimit > 0) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
+ signal,
|
||||
...options,
|
||||
headers,
|
||||
body: options.body,
|
||||
@@ -56,8 +64,8 @@ export class HttpClient {
|
||||
throw err;
|
||||
}
|
||||
if (response.status === 429 || response.status === 503) {
|
||||
- debug(`Received status=${response.status}, assume reached rate limit, retry in ${retryTimeout} ms`);
|
||||
retryLimit--;
|
||||
+ debug(`Received status=${response.status}, assume reached rate limit, retry in ${retryTimeout} ms with limit decreased to ${retryLimit}`);
|
||||
if (retryLimit > 0) {
|
||||
await this._delay(retryTimeout); // wait 200ms before retry
|
||||
continue;
|
||||
diff --git a/node_modules/musicbrainz-api/lib/musicbrainz-api-node.js b/node_modules/musicbrainz-api/lib/musicbrainz-api-node.js
|
||||
index c9cad5a..226cd5e 100644
|
||||
--- a/node_modules/musicbrainz-api/lib/musicbrainz-api-node.js
|
||||
+++ b/node_modules/musicbrainz-api/lib/musicbrainz-api-node.js
|
||||
@@ -15,7 +15,9 @@ export class MusicBrainzApi extends MusicBrainzApiDefault {
|
||||
return new HttpClientNode({
|
||||
baseUrl: this.config.baseUrl,
|
||||
timeout: 500,
|
||||
- userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`
|
||||
+ userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`,
|
||||
+ preRequest: this.config.preRequest,
|
||||
+ requestTimeout: this.config.requestTimeout
|
||||
});
|
||||
}
|
||||
async login() {
|
||||
diff --git a/node_modules/musicbrainz-api/lib/musicbrainz-api.d.ts b/node_modules/musicbrainz-api/lib/musicbrainz-api.d.ts
|
||||
index cd05017..4664ff0 100644
|
||||
--- a/node_modules/musicbrainz-api/lib/musicbrainz-api.d.ts
|
||||
+++ b/node_modules/musicbrainz-api/lib/musicbrainz-api.d.ts
|
||||
@@ -2,6 +2,7 @@ import type { XmlMetadata } from './xml/xml-metadata.js';
|
||||
import { RateLimitThreshold } from 'rate-limit-threshold';
|
||||
import * as mb from './musicbrainz.types.js';
|
||||
import { HttpClient, type MultiQueryFormData } from "./http-client.js";
|
||||
+import { Url } from 'node:url';
|
||||
export { XmlMetadata } from './xml/xml-metadata.js';
|
||||
export { XmlIsrc } from './xml/xml-isrc.js';
|
||||
export { XmlIsrcList } from './xml/xml-isrc-list.js';
|
||||
@@ -75,6 +76,9 @@ export interface IMusicBrainzConfig {
|
||||
* Default is [15, 18], which allows up to 15 requests every 18 seconds
|
||||
*/
|
||||
rateLimit?: [number, number];
|
||||
+ preRequest?: (method: string, url: string, headers: Headers) => [string,string,Headers]
|
||||
+ requestTimeout?: number
|
||||
+ retryLimit?: number
|
||||
}
|
||||
interface IInternalConfig extends IMusicBrainzConfig {
|
||||
baseUrl: string;
|
||||
diff --git a/node_modules/musicbrainz-api/lib/musicbrainz-api.js b/node_modules/musicbrainz-api/lib/musicbrainz-api.js
|
||||
index 72fb15d..f6bdfe5 100644
|
||||
--- a/node_modules/musicbrainz-api/lib/musicbrainz-api.js
|
||||
+++ b/node_modules/musicbrainz-api/lib/musicbrainz-api.js
|
||||
@@ -38,12 +38,15 @@ export class MusicBrainzApi {
|
||||
this.httpClient = this.initHttpClient();
|
||||
const limits = this.config.rateLimit ?? [15, 18];
|
||||
this.rateLimiter = new RateLimitThreshold(limits[0], limits[1]);
|
||||
+ this.retryLimit = this.config.retryLimit ?? 10;
|
||||
}
|
||||
initHttpClient() {
|
||||
return new HttpClient({
|
||||
baseUrl: this.config.baseUrl,
|
||||
timeout: 500,
|
||||
- userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`
|
||||
+ userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`,
|
||||
+ preRequest: this.config.preRequest,
|
||||
+ requestTimeout: this.config.requestTimeout
|
||||
});
|
||||
}
|
||||
async restGet(relUrl, query = {}) {
|
||||
@@ -51,7 +54,7 @@ export class MusicBrainzApi {
|
||||
await this.applyRateLimiter();
|
||||
const response = await this.httpClient.get(`/ws/2${relUrl}`, {
|
||||
query,
|
||||
- retryLimit: 10
|
||||
+ retryLimit: this.retryLimit
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -4,187 +4,50 @@ import {
|
||||
} from "@foxxmd/regex-buddy-core";
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
|
||||
import {
|
||||
configPartsToStrongParts, countRegexes,
|
||||
transformPlayUsingParts
|
||||
} from "../utils/PlayTransformUtils.js";
|
||||
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
|
||||
import { hasUpstreamError } from "./errors/UpstreamError.js";
|
||||
import {
|
||||
ConditionalSearchAndReplaceRegExp,
|
||||
PlayTransformParts, PlayTransformPartsArray,
|
||||
PlayTransformRules,
|
||||
TRANSFORM_HOOK,
|
||||
TransformHook
|
||||
} from "./infrastructure/Atomic.js";
|
||||
import { PlayData, PlayObject, TransformResult } from "../../core/Atomic.js";
|
||||
import { buildPlayHumanDiffable, buildTrackString } from "../../core/StringUtils.js";
|
||||
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
|
||||
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
|
||||
import { TransformRulesError } from "./errors/MSErrors.js";
|
||||
import {
|
||||
PlayTransformRules,
|
||||
StageConfig,
|
||||
TRANSFORM_HOOK,
|
||||
TransformHook
|
||||
} from "./infrastructure/Transform.js";
|
||||
import AbstractInitializable from "./AbstractInitializable.js";
|
||||
import play = Simulate.play;
|
||||
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
|
||||
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, TransformRulesError } from "./errors/MSErrors.js";
|
||||
import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js";
|
||||
import TransformerManager from "./transforms/TransformerManager.js";
|
||||
import { getRoot } from "../ioc.js";
|
||||
import { nanoid } from "nanoid";
|
||||
import {diffStringsUnified, DiffOptionsColor} from 'jest-diff';
|
||||
import chalk from 'chalk';
|
||||
import { isDebugMode } from "../utils.js";
|
||||
|
||||
export default abstract class AbstractComponent {
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authed: boolean = false;
|
||||
authFailure?: boolean;
|
||||
export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
buildOK?: boolean | null;
|
||||
connectionOK?: boolean | null;
|
||||
cacheOK?: boolean | null;
|
||||
|
||||
initializing: boolean = false;
|
||||
|
||||
config: CommonClientConfig | CommonSourceConfig;
|
||||
declare config: CommonClientConfig | CommonSourceConfig;
|
||||
|
||||
transformRules: PlayTransformRules = {};
|
||||
regexCache!: ReturnType<typeof cacheFunctions>;
|
||||
|
||||
logger: Logger;
|
||||
componentLogger?: Logger;
|
||||
protected transformManager: TransformerManager;
|
||||
|
||||
protected constructor(config: CommonClientConfig | CommonSourceConfig) {
|
||||
this.config = config;
|
||||
super(config);
|
||||
this.transformManager = getRoot().items.transformerManager;
|
||||
}
|
||||
|
||||
public abstract notify(payload: WebhookPayload): Promise<void>;
|
||||
|
||||
protected abstract getIdentifier(): string;
|
||||
|
||||
initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
|
||||
|
||||
const {force = false, notify = false, notifyTitle = 'Init Error'} = options;
|
||||
|
||||
this.logger.debug('Attempting to initialize...');
|
||||
protected postCache(): Promise<void> {
|
||||
try {
|
||||
this.initializing = true;
|
||||
if(this.componentLogger === undefined) {
|
||||
await this.buildComponentLogger();
|
||||
}
|
||||
await this.buildInitData(force);
|
||||
await this.parseCache(force);
|
||||
this.buildTransformRules();
|
||||
await this.checkConnection(force);
|
||||
await this.testAuth(force);
|
||||
this.logger.info('Fully Initialized!');
|
||||
try {
|
||||
await this.postInitialize();
|
||||
} catch (e) {
|
||||
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
|
||||
}
|
||||
return true;
|
||||
} catch(e) {
|
||||
if(notify) {
|
||||
await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
|
||||
}
|
||||
throw new Error('Initialization failed', {cause: e});
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async buildComponentLogger() {
|
||||
await this.doBuildComponentLogger();
|
||||
return;
|
||||
}
|
||||
|
||||
protected async doBuildComponentLogger() {
|
||||
return;
|
||||
}
|
||||
|
||||
tryInitialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
|
||||
if(this.initializing) {
|
||||
throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`)
|
||||
}
|
||||
try {
|
||||
return await this.initialize(options);
|
||||
return;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async parseCache(force: boolean = false) {
|
||||
if(this.cacheOK) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Cache OK but step was forced');
|
||||
}
|
||||
try {
|
||||
const res = await this.doParseCache();
|
||||
if(res === undefined) {
|
||||
this.cacheOK = null;
|
||||
this.logger.debug('No cache to parse.');
|
||||
return;
|
||||
}
|
||||
if (res === true) {
|
||||
this.logger.verbose('Parsing caching succeeded');
|
||||
} else if (typeof res === 'string') {
|
||||
this.logger.verbose(`Parsing caching succeeded => ${res}`);
|
||||
}
|
||||
this.cacheOK = true;
|
||||
} catch (e) {
|
||||
this.cacheOK = false;
|
||||
throw new ParseCacheError('Parsing cache for initialization failed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build or parse any cache required for this Component
|
||||
*
|
||||
* * Return undefined if not possible or not required
|
||||
* * Return TRUE if build succeeded
|
||||
* * Return string if build succeeded and should log result
|
||||
* * Throw error on failure
|
||||
* */
|
||||
protected async doParseCache(): Promise<true | string | undefined> {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
public async buildInitData(force: boolean = false) {
|
||||
if(this.buildOK) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Build OK but step was forced');
|
||||
}
|
||||
try {
|
||||
const res = await this.doBuildInitData();
|
||||
if(res === undefined) {
|
||||
this.buildOK = null;
|
||||
this.logger.debug('No required data to build.');
|
||||
return;
|
||||
}
|
||||
if (res === true) {
|
||||
this.logger.verbose('Building required data init succeeded');
|
||||
} else if (typeof res === 'string') {
|
||||
this.logger.verbose(`Building required data init succeeded => ${res}`);
|
||||
}
|
||||
this.buildOK = true;
|
||||
} catch (e) {
|
||||
this.buildOK = false;
|
||||
throw new BuildDataError('Building required data for initialization failed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build any data/config/objects required for this Source to communicate with upstream service
|
||||
*
|
||||
* * Return undefined if not possible or not required
|
||||
* * Return TRUE if build succeeded
|
||||
* * Return string if build succeeded and should log result
|
||||
* * Throw error on failure
|
||||
* */
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
return;
|
||||
}
|
||||
|
||||
public buildTransformRules() {
|
||||
this.logger.debug('Building transformer rules...');
|
||||
try {
|
||||
this.doBuildTransformRules();
|
||||
} catch (e) {
|
||||
@@ -192,8 +55,8 @@ export default abstract class AbstractComponent {
|
||||
throw new TransformRulesError('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
|
||||
}
|
||||
try {
|
||||
const ruleCount = countRegexes(this.transformRules);
|
||||
this.regexCache = cacheFunctions(ruleCount);
|
||||
//const ruleCount = countRegexes(this.transformRules);
|
||||
this.regexCache = cacheFunctions(200);
|
||||
} catch (e) {
|
||||
this.logger.warn(new TransformRulesError('Failed to count number of rule regexes for caching but will continue will fallback to 100', {cause: e}));
|
||||
}
|
||||
@@ -207,6 +70,7 @@ export default abstract class AbstractComponent {
|
||||
} = this.config;
|
||||
|
||||
if (playTransform === undefined) {
|
||||
this.logger.debug(`No rules found under property 'playTransform'`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -224,30 +88,54 @@ export default abstract class AbstractComponent {
|
||||
existing,
|
||||
postCompare;
|
||||
|
||||
const builtHooks: string[] = [];
|
||||
const emptyHooks: string[] = [];
|
||||
try {
|
||||
preCompare = configPartsToStrongParts(preConfig)
|
||||
preCompare = this.transformPartToStrong(preConfig);
|
||||
if(preCompare === undefined) {
|
||||
emptyHooks.push('preCompare')
|
||||
} else {
|
||||
builtHooks.push('preCompare');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('preCompare was not valid', {cause: e});
|
||||
}
|
||||
|
||||
try {
|
||||
candidate = configPartsToStrongParts(candidateConfig)
|
||||
candidate = this.transformPartToStrong(candidateConfig);
|
||||
if(candidate === undefined) {
|
||||
emptyHooks.push('candidate')
|
||||
} else {
|
||||
builtHooks.push('candidate');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('candidate was not valid', {cause: e});
|
||||
}
|
||||
|
||||
try {
|
||||
existing = configPartsToStrongParts(existingConfig)
|
||||
existing = this.transformPartToStrong(existingConfig);
|
||||
if(existing === undefined) {
|
||||
emptyHooks.push('existing')
|
||||
} else {
|
||||
builtHooks.push('existing');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('existing was not valid', {cause: e});
|
||||
}
|
||||
|
||||
try {
|
||||
postCompare = configPartsToStrongParts(postConfig)
|
||||
postCompare = this.transformPartToStrong(postConfig);
|
||||
if(postCompare === undefined) {
|
||||
emptyHooks.push('postCompare')
|
||||
} else {
|
||||
builtHooks.push('postCompare');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('postCompare was not valid', {cause: e});
|
||||
}
|
||||
|
||||
this.logger.debug(`Hooks built. Configured: ${builtHooks.join(', ')} | Empty: ${emptyHooks.join(', ')}`);
|
||||
|
||||
this.transformRules = {
|
||||
preCompare,
|
||||
compare: {
|
||||
@@ -258,112 +146,24 @@ export default abstract class AbstractComponent {
|
||||
}
|
||||
}
|
||||
|
||||
public async checkConnection(force: boolean = false) {
|
||||
if(this.connectionOK) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Connection OK but step was forced')
|
||||
}
|
||||
try {
|
||||
const res = await this.doCheckConnection();
|
||||
if (res === undefined) {
|
||||
this.logger.debug('Connection check was not required.');
|
||||
this.connectionOK = null;
|
||||
return;
|
||||
} else if (res === true) {
|
||||
this.logger.verbose('Connection check succeeded');
|
||||
} else {
|
||||
this.logger.verbose(`Connection check succeeded => ${res}`);
|
||||
}
|
||||
this.connectionOK = true;
|
||||
} catch (e) {
|
||||
this.connectionOK = false;
|
||||
throw new ConnectionCheckError('Communicating with upstream service failed', {cause: e});
|
||||
protected transformPartToStrong(data: any) {
|
||||
if(data === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// default to user transform type for backward compatibility
|
||||
const partArr = (Array.isArray(data) ? data : [data]).map(x => ({type: 'user', ...x}));
|
||||
|
||||
return partArr.map(x => this.transformManager.parseTransformerConfig(x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Scrobbler upstream API/connection to ensure we can communicate
|
||||
*
|
||||
* * Return undefined if not possible or not required to check
|
||||
* * Return TRUE if communication succeeded
|
||||
* * Return string if communication succeeded and should log result
|
||||
* * Throw error if communication failed
|
||||
* */
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
return;
|
||||
}
|
||||
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean) => {
|
||||
|
||||
authGated = () => this.requiresAuth && !this.authed
|
||||
|
||||
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
|
||||
|
||||
canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure);
|
||||
|
||||
protected doAuthentication = async (): Promise<boolean> => this.authed
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async (force: boolean = false) => {
|
||||
if(!this.requiresAuth) {
|
||||
return;
|
||||
}
|
||||
if(this.authed) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Auth OK but step was forced');
|
||||
}
|
||||
|
||||
if(this.authFailure) {
|
||||
if(!force) {
|
||||
if(this.requiresAuthInteraction) {
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication');
|
||||
}
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated');
|
||||
}
|
||||
this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced');
|
||||
}
|
||||
const asyncId = nanoid(6);
|
||||
let logger = childLogger(this.logger, ['Play Transform', hookType, asyncId]);
|
||||
|
||||
try {
|
||||
this.authed = await this.doAuthentication();
|
||||
this.authFailure = !this.authed;
|
||||
} catch (e) {
|
||||
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
|
||||
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
|
||||
this.authed = false;
|
||||
throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
public isReady() {
|
||||
return (this.buildOK === null || this.buildOK === true) &&
|
||||
(this.connectionOK === null || this.connectionOK === true)
|
||||
&& !this.authGated();
|
||||
}
|
||||
|
||||
public isUsable() {
|
||||
return (this.buildOK === null || this.buildOK === true) &&
|
||||
(this.connectionOK === null || this.connectionOK === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to perform some action after successfully initializing
|
||||
*
|
||||
* Results will be try-catched and swallowed/logged if an error is thrown. This will not affect initialized state.
|
||||
* */
|
||||
protected async postInitialize(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public transformPlay = (play: PlayObject, hookType: TransformHook, log?: boolean) => {
|
||||
|
||||
let logger: Logger;
|
||||
const labels = ['Play Transform', hookType];
|
||||
const getLogger = () => logger !== undefined ? logger : childLogger(this.logger, labels);
|
||||
|
||||
try {
|
||||
let hook: PlayTransformPartsArray<ConditionalSearchAndReplaceRegExp> | undefined;
|
||||
let hook: StageConfig[];
|
||||
|
||||
switch (hookType) {
|
||||
case TRANSFORM_HOOK.preCompare:
|
||||
@@ -384,42 +184,107 @@ export default abstract class AbstractComponent {
|
||||
return play;
|
||||
}
|
||||
|
||||
logger.debug(`Transform start for => ${buildTrackString(play)}`);
|
||||
let transformedPlay: PlayObject = play;
|
||||
const transformDetails: string[] = [];
|
||||
let transformHistory: TransformResult[] = [];
|
||||
for(const hookItem of hook) {
|
||||
const newTransformedPlay = transformPlayUsingParts(transformedPlay, hookItem, {
|
||||
logger: getLogger,
|
||||
regex: {
|
||||
searchAndReplace: this.regexCache.searchAndReplace,
|
||||
testMaybeRegex: this.regexCache.testMaybeRegex,
|
||||
}
|
||||
});
|
||||
if(!deepEqual(newTransformedPlay, transformedPlay)) {
|
||||
transformDetails.push(buildTrackString(transformedPlay, {include: ['artist', 'track', 'album']}));
|
||||
|
||||
const {
|
||||
onSuccess = 'continue',
|
||||
onFailure = 'stop',
|
||||
failureReturnPartial = false
|
||||
} = hookItem;
|
||||
|
||||
let newTransformedPlay: PlayObject,
|
||||
stageName: string,
|
||||
err: Error;
|
||||
try {
|
||||
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, transformedPlay, asyncId);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
|
||||
if(err !== undefined) {
|
||||
if(onFailure === 'continue') {
|
||||
logger.warn(new Error(`A transform encountered an error but continuing due to onFailure: continue`, {cause: err}));
|
||||
} else {
|
||||
logger.error(new Error(`Transform encountered an error`, {cause: err}));
|
||||
if(!failureReturnPartial) {
|
||||
// rewind to original play so we don't return partial transform
|
||||
transformedPlay = play;
|
||||
transformHistory = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
transformHistory.push({
|
||||
type: hookItem.type,
|
||||
name: stageName,
|
||||
play: newTransformedPlay.data
|
||||
});
|
||||
|
||||
transformedPlay = newTransformedPlay;
|
||||
|
||||
if(err === undefined && onSuccess === 'stop') {
|
||||
logger.debug(`${nanoid} Stopping transform due to onSuccess: stop`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(transformDetails.length > 0) {
|
||||
let transformStatements = [`Original: ${buildTrackString(play, {include: ['artist', 'track', 'album']})}`];
|
||||
const shouldLog = log ?? this.config.options?.playTransform?.log ?? false;
|
||||
if (shouldLog === true || shouldLog === 'all') {
|
||||
if(shouldLog === 'all') {
|
||||
transformStatements = transformStatements.concat(transformDetails.map(x => `=> ${x}`));
|
||||
} else {
|
||||
transformStatements.push(`=> ${transformDetails[transformDetails.length - 1]}`);
|
||||
}
|
||||
this.logger.debug({labels: [...labels, hookType]}, `Transform Pipeline:\n${transformStatements.join('\n')}`);
|
||||
}
|
||||
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
|
||||
|
||||
if(transformedPlay.meta.transforms === undefined) {
|
||||
transformedPlay.meta.transforms = {
|
||||
original: play.data
|
||||
};
|
||||
}
|
||||
if(shouldLog !== false) {
|
||||
if(transformHistory.length === 0) {
|
||||
logger.debug('Transform Diff: No Change');
|
||||
} else {
|
||||
const historyToDiff: {name: string, data: PlayData}[] = [
|
||||
{name: 'Original', data: transformedPlay.meta.transforms.original}
|
||||
];
|
||||
if(shouldLog === true) {
|
||||
const last = transformHistory[transformHistory.length - 1];
|
||||
historyToDiff.push({name: `${last.type}-${last.name}`, data: last.play});
|
||||
} else {
|
||||
for(const t of transformHistory) {
|
||||
historyToDiff.push({name: `${t.type}-${t.name}`, data: t.play})
|
||||
}
|
||||
}
|
||||
const diffs: string[] = [];
|
||||
historyToDiff.forEach((curr, index) => {
|
||||
if(index === 0) {
|
||||
return;
|
||||
}
|
||||
const last = historyToDiff[index - 1];
|
||||
if(deepEqual(last.data, curr.data)) {
|
||||
diffs.push(`${last.name} => ${curr.name} -- No Change`);
|
||||
} else {
|
||||
diffs.push(diffStringsUnified(
|
||||
buildPlayHumanDiffable(last.data, {expandMeta: true}),
|
||||
buildPlayHumanDiffable(curr.data, {expandMeta: true}),
|
||||
{
|
||||
aAnnotation: last.name,
|
||||
aColor: chalk.red,
|
||||
bAnnotation: curr.name,
|
||||
bColor: chalk.green
|
||||
}
|
||||
))
|
||||
}
|
||||
});
|
||||
logger.debug(`Transform Diff\n${diffs}`);
|
||||
}
|
||||
|
||||
}
|
||||
const previousHistory = transformedPlay.meta.transforms[hookType] ?? [];
|
||||
transformedPlay.meta.transforms[hookType] = [...previousHistory, ...transformHistory];
|
||||
return transformedPlay;
|
||||
} catch (e) {
|
||||
getLogger().warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
|
||||
logger.warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
|
||||
return play;
|
||||
}
|
||||
}
|
||||
|
||||
public additionalApiData(): Record<string, any> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import {truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
|
||||
import { hasUpstreamError } from "./errors/UpstreamError.js";
|
||||
import play = Simulate.play;
|
||||
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
|
||||
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError, TransformRulesError } from "./errors/MSErrors.js";
|
||||
import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js";
|
||||
|
||||
export default abstract class AbstractInitializable {
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authed: boolean = false;
|
||||
authFailure?: boolean;
|
||||
|
||||
buildOK?: boolean | null;
|
||||
connectionOK?: boolean | null;
|
||||
cacheOK?: boolean | null;
|
||||
|
||||
initializing: boolean = false;
|
||||
|
||||
config: Record<string, any>;
|
||||
|
||||
logger: Logger;
|
||||
componentLogger?: Logger;
|
||||
|
||||
protected constructor(config: Record<string, any>) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public abstract notify(payload: WebhookPayload): Promise<void>;
|
||||
|
||||
protected abstract getIdentifier(): string;
|
||||
|
||||
initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
|
||||
|
||||
const {force = false, notify = false, notifyTitle = 'Init Error'} = options;
|
||||
|
||||
this.logger.debug('Attempting to initialize...');
|
||||
try {
|
||||
this.initializing = true;
|
||||
if(this.componentLogger === undefined) {
|
||||
await this.buildComponentLogger();
|
||||
}
|
||||
await this.buildInitData(force);
|
||||
await this.parseCache(force);
|
||||
try {
|
||||
await this.postCache();
|
||||
} catch (e) {
|
||||
if(e instanceof StageError) {
|
||||
throw e;
|
||||
} else {
|
||||
throw new Error('Error occurred during post-cache hook', {cause: e});
|
||||
}
|
||||
}
|
||||
await this.checkConnection(force);
|
||||
await this.testAuth(force);
|
||||
this.logger.info('Fully Initialized!');
|
||||
try {
|
||||
await this.postInitialize();
|
||||
} catch (e) {
|
||||
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
|
||||
}
|
||||
return true;
|
||||
} catch(e) {
|
||||
if(notify) {
|
||||
await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
|
||||
}
|
||||
throw new Error('Initialization failed', {cause: e});
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected async buildComponentLogger() {
|
||||
await this.doBuildComponentLogger();
|
||||
return;
|
||||
}
|
||||
|
||||
protected async doBuildComponentLogger() {
|
||||
return;
|
||||
}
|
||||
|
||||
tryInitialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
|
||||
if(this.initializing) {
|
||||
throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`)
|
||||
}
|
||||
try {
|
||||
return await this.initialize(options);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async parseCache(force: boolean = false) {
|
||||
if(this.cacheOK) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Cache OK but step was forced');
|
||||
}
|
||||
try {
|
||||
const res = await this.doParseCache();
|
||||
if(res === undefined) {
|
||||
this.cacheOK = null;
|
||||
this.logger.debug('No cache to parse.');
|
||||
return;
|
||||
}
|
||||
if (res === true) {
|
||||
this.logger.verbose('Parsing caching succeeded');
|
||||
} else if (typeof res === 'string') {
|
||||
this.logger.verbose(`Parsing caching succeeded => ${res}`);
|
||||
}
|
||||
this.cacheOK = true;
|
||||
} catch (e) {
|
||||
this.cacheOK = false;
|
||||
throw new ParseCacheError('Parsing cache for initialization failed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build or parse any cache required for this Component
|
||||
*
|
||||
* * Return undefined if not possible or not required
|
||||
* * Return TRUE if build succeeded
|
||||
* * Return string if build succeeded and should log result
|
||||
* * Throw error on failure
|
||||
* */
|
||||
protected async doParseCache(): Promise<true | string | undefined> {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
protected async postCache(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public async buildInitData(force: boolean = false) {
|
||||
if(this.buildOK) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Build OK but step was forced');
|
||||
}
|
||||
try {
|
||||
const res = await this.doBuildInitData();
|
||||
if(res === undefined) {
|
||||
this.buildOK = null;
|
||||
this.logger.debug('No required data to build.');
|
||||
return;
|
||||
}
|
||||
if (res === true) {
|
||||
this.logger.verbose('Building required data init succeeded');
|
||||
} else if (typeof res === 'string') {
|
||||
this.logger.verbose(`Building required data init succeeded => ${res}`);
|
||||
}
|
||||
this.buildOK = true;
|
||||
} catch (e) {
|
||||
this.buildOK = false;
|
||||
throw new BuildDataError('Building required data for initialization failed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build any data/config/objects required for this Source to communicate with upstream service
|
||||
*
|
||||
* * Return undefined if not possible or not required
|
||||
* * Return TRUE if build succeeded
|
||||
* * Return string if build succeeded and should log result
|
||||
* * Throw error on failure
|
||||
* */
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
return;
|
||||
}
|
||||
|
||||
public async checkConnection(force: boolean = false) {
|
||||
if(this.connectionOK) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Connection OK but step was forced')
|
||||
}
|
||||
try {
|
||||
const res = await this.doCheckConnection();
|
||||
if (res === undefined) {
|
||||
this.logger.debug('Connection check was not required.');
|
||||
this.connectionOK = null;
|
||||
return;
|
||||
} else if (res === true) {
|
||||
this.logger.verbose('Connection check succeeded');
|
||||
} else {
|
||||
this.logger.verbose(`Connection check succeeded => ${res}`);
|
||||
}
|
||||
this.connectionOK = true;
|
||||
} catch (e) {
|
||||
this.connectionOK = false;
|
||||
throw new ConnectionCheckError('Communicating with upstream service failed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Scrobbler upstream API/connection to ensure we can communicate
|
||||
*
|
||||
* * Return undefined if not possible or not required to check
|
||||
* * Return TRUE if communication succeeded
|
||||
* * Return string if communication succeeded and should log result
|
||||
* * Throw error if communication failed
|
||||
* */
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
return;
|
||||
}
|
||||
|
||||
authGated = () => this.requiresAuth && !this.authed
|
||||
|
||||
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
|
||||
|
||||
canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure);
|
||||
|
||||
protected doAuthentication = async (): Promise<boolean> => this.authed
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async (force: boolean = false) => {
|
||||
if(!this.requiresAuth) {
|
||||
return;
|
||||
}
|
||||
if(this.authed) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Auth OK but step was forced');
|
||||
}
|
||||
|
||||
if(this.authFailure) {
|
||||
if(!force) {
|
||||
if(this.requiresAuthInteraction) {
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication');
|
||||
}
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated');
|
||||
}
|
||||
this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced');
|
||||
}
|
||||
|
||||
try {
|
||||
this.authed = await this.doAuthentication();
|
||||
this.authFailure = !this.authed;
|
||||
} catch (e) {
|
||||
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
|
||||
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
|
||||
this.authed = false;
|
||||
throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
public isReady() {
|
||||
return (this.buildOK === null || this.buildOK === true) &&
|
||||
(this.connectionOK === null || this.connectionOK === true)
|
||||
&& !this.authGated();
|
||||
}
|
||||
|
||||
public isUsable() {
|
||||
return (this.buildOK === null || this.buildOK === true) &&
|
||||
(this.connectionOK === null || this.connectionOK === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to perform some action after successfully initializing
|
||||
*
|
||||
* Results will be try-catched and swallowed/logged if an error is thrown. This will not affect initialized state.
|
||||
* */
|
||||
protected async postInitialize(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public additionalApiData(): Record<string, any> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@ import clone from 'clone';
|
||||
import { childLogger, Logger } from '@foxxmd/logging';
|
||||
import { projectDir } from './index.js';
|
||||
import path from 'path';
|
||||
import { cacheFunctions } from "@foxxmd/regex-buddy-core";
|
||||
import { fileOrDirectoryIsWriteable } from '../utils.js';
|
||||
import { asCacheMetadataProvider, asCacheScrobbleProvider, CacheConfig, CacheConfigOptions, CacheMetadaProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js';
|
||||
import { asCacheAuthProvider, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js';
|
||||
import { Typeson } from 'typeson';
|
||||
import { builtin } from 'typeson-registry';
|
||||
import { MaybeLogger } from './logging.js';
|
||||
@@ -46,6 +47,9 @@ export class MSCache {
|
||||
|
||||
cacheMetadata: Cacheable;
|
||||
cacheScrobble: Cacheable;
|
||||
cacheAuth: Cacheable;
|
||||
regexCache: ReturnType<typeof cacheFunctions>;
|
||||
cacheTransform: Cacheable;
|
||||
|
||||
logger: Logger;
|
||||
|
||||
@@ -54,7 +58,7 @@ export class MSCache {
|
||||
|
||||
const {
|
||||
metadata: {
|
||||
provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadaProvider | undefined) ?? 'memory'),
|
||||
provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadataProvider | undefined) ?? 'memory'),
|
||||
connection: mConn = process.env.CACHE_METADATA_CONN,
|
||||
...restMetadata
|
||||
} = {},
|
||||
@@ -63,6 +67,12 @@ export class MSCache {
|
||||
connection = (process.env.CACHE_SCROBBLE_CONN ?? configDir),
|
||||
...restScrobble
|
||||
} = {},
|
||||
auth: {
|
||||
provider: aProvider = (process.env.CACHE_AUTH as (CacheAuthProvider | undefined) ?? 'file'),
|
||||
connection: aConn = (process.env.CACHE_AUTH_CONN ?? configDir),
|
||||
...restAuth
|
||||
} = {},
|
||||
regex = 200,
|
||||
} = config;
|
||||
|
||||
this.config = {
|
||||
@@ -75,14 +85,24 @@ export class MSCache {
|
||||
provider: sProvider,
|
||||
connection,
|
||||
...restScrobble
|
||||
}
|
||||
},
|
||||
auth: {
|
||||
provider: aProvider,
|
||||
connection: aConn,
|
||||
...restAuth
|
||||
},
|
||||
regex
|
||||
};
|
||||
|
||||
this.regexCache = cacheFunctions(this.config.regex);
|
||||
this.cacheTransform = new Cacheable({primary: initMemoryCache({lruSize: 500})});
|
||||
}
|
||||
|
||||
init = async () => {
|
||||
// disabled for now
|
||||
//await this.initMetadataCache();
|
||||
await this.initMetadataCache();
|
||||
await this.initScrobbleCache();
|
||||
await this.initAuthCache();
|
||||
//this.cacheTransform = await this.initCacheable({provider: false, memory: {lruSize: 500}}, 'transform');
|
||||
}
|
||||
|
||||
protected initCacheable = async (config: CacheConfig, cacheFor: string) => {
|
||||
@@ -97,7 +117,7 @@ export class MSCache {
|
||||
const ns = `ms-${cacheFor.toLocaleLowerCase()}`;
|
||||
|
||||
const cacheOpts: CacheableOptions = {
|
||||
primary: initMemoryCache({ namespace: ns })
|
||||
primary: initMemoryCache({ namespace: ns, lruSize: config.memory?.lruSize, ttl: config.memory?.ttl })
|
||||
}
|
||||
|
||||
let secondaryCache: Keyv | KeyvStoreAdapter | undefined;
|
||||
@@ -147,14 +167,30 @@ export class MSCache {
|
||||
this.cacheMetadata = await this.initCacheable(this.config.metadata, 'Metadata');
|
||||
}
|
||||
}
|
||||
|
||||
initAuthCache = async () => {
|
||||
if (this.cacheAuth === undefined) {
|
||||
if (!asCacheAuthProvider(this.config.auth.provider)) {
|
||||
throw new Error(`Cache Auth provider '${this.config.auth.provider}' must be one of: memory, valkey, file`);
|
||||
}
|
||||
|
||||
this.cacheAuth = await this.initCacheable(this.config.auth, 'Auth');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const initMemoryCache = (opts: Parameters<typeof createKeyv>[0] = {}): Keyv | KeyvStoreAdapter => {
|
||||
const {
|
||||
ttl = '1h',
|
||||
lruSize = 200,
|
||||
...restOpts
|
||||
} = opts;
|
||||
const memory = createKeyv({
|
||||
ttl: '1h',
|
||||
lruSize: 200,
|
||||
...opts,
|
||||
ttl,
|
||||
lruSize,
|
||||
...restOpts,
|
||||
useClone: false,
|
||||
});
|
||||
// structuredClone does not work well with dayjs https://github.com/iamkun/dayjs/issues/2236
|
||||
|
||||
@@ -26,4 +26,27 @@ export class AuthCheckError extends StageError {
|
||||
|
||||
export class PostInitError extends StageError {
|
||||
name = 'Post Initialization';
|
||||
}
|
||||
|
||||
export class SimpleError extends Error implements HasSimpleError {
|
||||
simple: boolean;
|
||||
|
||||
public constructor(msg: string, options?: ErrorOptions & { simple?: boolean }) {
|
||||
super(msg, options);
|
||||
const {
|
||||
simple = true
|
||||
} = options || {};
|
||||
this.simple = simple;
|
||||
}
|
||||
}
|
||||
|
||||
export interface HasSimpleError extends Error {
|
||||
simple: boolean
|
||||
}
|
||||
|
||||
export const isSimpleError = (e: unknown): e is HasSimpleError => {
|
||||
if(!(e instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
return 'simple' in e;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Logger } from '@foxxmd/logging';
|
||||
import { SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { Request, Response } from "express";
|
||||
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { isPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js";
|
||||
import TupleMap from "../TupleMap.js";
|
||||
import { MusicBrainzApi } from 'musicbrainz-api';
|
||||
|
||||
export type SourceType =
|
||||
'spotify'
|
||||
@@ -32,7 +32,9 @@ export type SourceType =
|
||||
| 'vlc'
|
||||
| 'icecast'
|
||||
| 'azuracast'
|
||||
| 'koito';
|
||||
| 'koito'
|
||||
| 'tealfm'
|
||||
| 'rocksky';
|
||||
|
||||
export const sourceTypes: SourceType[] = [
|
||||
'spotify',
|
||||
@@ -59,7 +61,9 @@ export const sourceTypes: SourceType[] = [
|
||||
'vlc',
|
||||
'icecast',
|
||||
'azuracast',
|
||||
'koito'
|
||||
'koito',
|
||||
'tealfm',
|
||||
'rocksky'
|
||||
];
|
||||
|
||||
export const isSourceType = (data: string): data is SourceType => {
|
||||
@@ -72,12 +76,16 @@ export type ClientType =
|
||||
'maloja'
|
||||
| 'lastfm'
|
||||
| 'listenbrainz'
|
||||
| 'koito';
|
||||
| 'koito'
|
||||
| 'tealfm'
|
||||
| 'rocksky';
|
||||
export const clientTypes: ClientType[] = [
|
||||
'maloja',
|
||||
'lastfm',
|
||||
'listenbrainz',
|
||||
'koito'
|
||||
'koito',
|
||||
'tealfm',
|
||||
'rocksky'
|
||||
];
|
||||
export const isClientType = (data: string): data is ClientType => {
|
||||
return clientTypes.includes(data as ClientType);
|
||||
@@ -260,6 +268,8 @@ export interface numberFormatOptions {
|
||||
}
|
||||
|
||||
export const DELIMITERS = [',','&','/','\\'];
|
||||
export const DELIMITERS_NO_AMP = [',','/','\\'];
|
||||
export const DELIMETERS_REGEX: RegExp = new RegExp(/[,&\/\\]/);
|
||||
|
||||
export const ARTIST_WEIGHT = 0.3;
|
||||
export const TITLE_WEIGHT = 0.4;
|
||||
@@ -291,61 +301,6 @@ export type AbstractApiOptions = Record<any, any> & { logger: Logger }
|
||||
|
||||
export type keyOmit<T, U extends keyof any> = T & { [P in U]?: never }
|
||||
|
||||
export interface ConditionalSearchAndReplaceRegExp extends SearchAndReplaceRegExp{
|
||||
when?: WhenConditionsConfig
|
||||
}
|
||||
|
||||
export type ConditionalSearchAndReplaceTerm = Omit<ConditionalSearchAndReplaceRegExp, 'test'>
|
||||
|
||||
export type SearchAndReplaceTerm = string | ConditionalSearchAndReplaceTerm;
|
||||
|
||||
export type PlayTransformParts<T> = PlayTransformPartsAtomic<T[]> & { when?: WhenConditionsConfig };
|
||||
|
||||
export type PlayTransformPartsArray<T> = PlayTransformParts<T>[];
|
||||
|
||||
export type PlayTransformPartsConfig<T> = PlayTransformPartsArray<T> | PlayTransformParts<T>;
|
||||
|
||||
export interface PlayTransformPartsAtomic<T> {
|
||||
title?: T
|
||||
artists?: T
|
||||
album?: T
|
||||
}
|
||||
|
||||
export interface PlayTransformHooksConfig<T> {
|
||||
preCompare?: PlayTransformPartsConfig<T>
|
||||
compare?: {
|
||||
candidate?: PlayTransformPartsConfig<T>
|
||||
existing?: PlayTransformPartsConfig<T>
|
||||
}
|
||||
postCompare?: PlayTransformPartsConfig<T>
|
||||
}
|
||||
|
||||
export interface PlayTransformHooks<T> extends PlayTransformHooksConfig<T> {
|
||||
preCompare?: PlayTransformPartsArray<T>
|
||||
compare?: {
|
||||
candidate?: PlayTransformPartsArray<T>
|
||||
existing?: PlayTransformPartsArray<T>
|
||||
}
|
||||
postCompare?: PlayTransformPartsArray<T>
|
||||
}
|
||||
|
||||
export type PlayTransformRules = PlayTransformHooks<ConditionalSearchAndReplaceRegExp>
|
||||
|
||||
export type TransformHook = 'preCompare' | 'compare' | 'candidate' | 'existing' | 'postCompare';
|
||||
export const TRANSFORM_HOOK = {
|
||||
preCompare: 'preCompare' as TransformHook,
|
||||
candidate: 'candidate' as TransformHook,
|
||||
existing: 'existing' as TransformHook,
|
||||
postCompare: 'postCompare' as TransformHook,
|
||||
}
|
||||
export type PlayTransformConfig = PlayTransformHooksConfig<SearchAndReplaceTerm>;
|
||||
export type PlayTransformOptions = PlayTransformConfig & { log?: boolean | 'all' }
|
||||
|
||||
export type WhenParts<T> = PlayTransformPartsAtomic<T>;
|
||||
|
||||
export type WhenConditions<T> = WhenParts<T>[];
|
||||
export type WhenConditionsConfig = WhenConditions<string>;
|
||||
|
||||
export type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
|
||||
[Property in Key]-?: Type[Property];
|
||||
};
|
||||
@@ -355,8 +310,8 @@ export interface CacheConfig<T extends CacheProvider = CacheProvider> {
|
||||
connection?: string;
|
||||
[key: string]: any
|
||||
}
|
||||
export type CacheMetadaProvider = CacheProvider;//Exclude<CacheProvider, 'file'>;
|
||||
export type CacheMetadataConfig = CacheConfig<CacheMetadaProvider>;
|
||||
export type CacheMetadataProvider = CacheProvider;//Exclude<CacheProvider, 'file'>;
|
||||
export type CacheMetadataConfig = CacheConfig<CacheMetadataProvider>;
|
||||
export const asCacheProvider = (val: boolean | string): val is CacheProvider => {
|
||||
if(typeof val === 'string') {
|
||||
return ['memory', 'valkey', 'file'].includes(val);
|
||||
@@ -367,8 +322,30 @@ export const asCacheMetadataProvider = (val: any): val is CacheScrobbleProvider
|
||||
export type CacheScrobbleProvider = CacheProvider;
|
||||
export type CacheScrobbleConfig = CacheConfig<CacheScrobbleProvider>;
|
||||
export const asCacheScrobbleProvider = (val: any): val is CacheScrobbleProvider => asCacheProvider(val);
|
||||
|
||||
export type CacheAuthProvider = CacheProvider;
|
||||
export type CacheAuthConfig = CacheConfig<CacheAuthProvider>;
|
||||
export const asCacheAuthProvider = (val: any): val is CacheAuthProvider => asCacheProvider(val);
|
||||
export interface CacheConfigOptions {
|
||||
metadata?: CacheMetadataConfig;
|
||||
scrobble?: CacheScrobbleConfig;
|
||||
auth?: CacheAuthConfig;
|
||||
/** Number of regex functions to cache (LRU)
|
||||
*
|
||||
* @default 200
|
||||
*/
|
||||
regex?: number
|
||||
}
|
||||
|
||||
export interface MusicbrainzApiConfigData {
|
||||
url?: string
|
||||
rateLimit?: [number, number]
|
||||
contact: string,
|
||||
ttl?: string
|
||||
apiKey?: string
|
||||
requestTimeout?: number
|
||||
}
|
||||
|
||||
export const MUSICBRAINZ_URL = 'https://musicbrainz.org';
|
||||
|
||||
export type MusicBrainzSingletonMap = Map<string,MusicBrainzApi>;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core";
|
||||
|
||||
export interface ConditionalSearchAndReplaceRegExp extends SearchAndReplaceRegExp, Whennable {
|
||||
}
|
||||
|
||||
export type ConditionalSearchAndReplaceTerm = Omit<ConditionalSearchAndReplaceRegExp, 'test'>
|
||||
export type SearchAndReplaceTerm = string | ConditionalSearchAndReplaceTerm;
|
||||
export type ExternalMetadataTerm = boolean | undefined | Whennable;
|
||||
|
||||
export type PlayTransformParts<T, Y = MaybeStageTyped> = Extract<PlayTransformStage<T>, Y> & Whennable;
|
||||
//export type PlayTransformUserParts<T> = PlayTransformUserStage<T[]> & { when?: WhenConditionsConfig };
|
||||
//export type PlayTransformMetaParts<T = ExternalMetadataTerm> = PlayTransformMetadataStage<T> & { when?: WhenConditionsConfig };
|
||||
export type PlayTransformPartsArray<T, Y = MaybeStageTyped> = PlayTransformParts<T, Y>[];
|
||||
|
||||
/** Represents the weakly-defined user config. May be an array of parts or one parts object */
|
||||
export type PlayTransformPartsConfig<T, Y = MaybeStageTyped> = PlayTransformPartsArray<T, Y> | PlayTransformParts<T, Y>;
|
||||
|
||||
export interface PlayTransformPartsAtomic<T> {
|
||||
title?: T
|
||||
artists?: T
|
||||
albumArtists?: T
|
||||
album?: T
|
||||
duration?: T
|
||||
meta?: T
|
||||
}
|
||||
|
||||
export type StageTypeMetadata = 'spotify' | 'musicbrainz' | 'native';
|
||||
export type StageTypeUser = 'user';
|
||||
export type StageType = StageTypeMetadata | StageTypeUser | string;
|
||||
export const STAGE_TYPES_USER: StageTypeUser[] = ['user'];
|
||||
export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['spotify','musicbrainz','native'];
|
||||
export const STAGE_TYPES: StageType[] = [...STAGE_TYPES_METADATA, ...STAGE_TYPES_USER];
|
||||
|
||||
export interface StageTyped {
|
||||
type: StageType
|
||||
}
|
||||
|
||||
export interface NotStageTyped {
|
||||
type?: never
|
||||
}
|
||||
|
||||
export type MaybeStageTyped = StageTyped | NotStageTyped;
|
||||
|
||||
export interface StageTypedConfig {
|
||||
type: StageType
|
||||
}
|
||||
|
||||
export interface Whennable {
|
||||
when?: WhenConditionsConfig
|
||||
}
|
||||
|
||||
export type FlowControlTerm = 'continue' | 'stop'
|
||||
|
||||
export interface FlowControl {
|
||||
onSuccess: FlowControlTerm
|
||||
onFailure: FlowControlTerm
|
||||
failureReturnPartial: boolean
|
||||
}
|
||||
|
||||
export interface StageConfig extends StageTypedConfig, Whennable, Partial<FlowControl> {
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface AtomicStageConfig<T> extends StageConfig, PlayTransformPartsAtomic<T> {}
|
||||
|
||||
export interface PlayTransformStageTyped<T> extends PlayTransformPartsAtomic<T> {
|
||||
type: StageType
|
||||
}
|
||||
|
||||
export interface PlayTransformMetadataStage extends StageConfig, PlayTransformPartsAtomic<ExternalMetadataTerm> {
|
||||
score?: number
|
||||
// all?: ExternalMetadataTerm
|
||||
type: StageTypeMetadata
|
||||
}
|
||||
|
||||
export interface PlayTransformUserStage<T> extends StageConfig, PlayTransformPartsAtomic<T> {
|
||||
type: StageTypeUser
|
||||
}
|
||||
|
||||
export interface PlayTransformNativeStage extends StageConfig, PlayTransformPartsAtomic<ExternalMetadataTerm> {
|
||||
type: 'native'
|
||||
}
|
||||
|
||||
export interface PlayTransformGenericStage<T> extends StageConfig, PlayTransformPartsAtomic<T> {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type UntypedPlayTransformUserStage<T> = Omit<PlayTransformUserStage<T>, 'type'> & {type?: never};
|
||||
|
||||
export type PlayTransformStage<T> = PlayTransformMetadataStage | PlayTransformUserStage<T> | PlayTransformNativeStage | UntypedPlayTransformUserStage<T> | PlayTransformGenericStage<any>;
|
||||
|
||||
/** Represents the plain json user-configured structure (input) */
|
||||
export interface PlayTransformHooksConfig<T> {
|
||||
preCompare?: PlayTransformPartsConfig<T>
|
||||
compare?: {
|
||||
candidate?: PlayTransformPartsConfig<T>
|
||||
existing?: PlayTransformPartsConfig<T>
|
||||
}
|
||||
postCompare?: PlayTransformPartsConfig<T>
|
||||
}
|
||||
|
||||
/** Represents the final, strongly-typed transform configuration used during runtime */
|
||||
export interface PlayTransformHooks<T> extends PlayTransformHooksConfig<T> {
|
||||
preCompare?: PlayTransformPartsArray<T, StageTyped>
|
||||
compare?: {
|
||||
candidate?: PlayTransformPartsArray<T, StageTyped>
|
||||
existing?: PlayTransformPartsArray<T, StageTyped>
|
||||
}
|
||||
postCompare?: PlayTransformPartsArray<T, StageTyped>
|
||||
}
|
||||
|
||||
export type PlayTransformRules = PlayTransformHooks<ConditionalSearchAndReplaceRegExp[] | ExternalMetadataTerm>
|
||||
export type TransformHook = 'preCompare' | 'compare' | 'candidate' | 'existing' | 'postCompare';
|
||||
export const TRANSFORM_HOOK = {
|
||||
preCompare: 'preCompare' as TransformHook,
|
||||
candidate: 'candidate' as TransformHook,
|
||||
existing: 'existing' as TransformHook,
|
||||
postCompare: 'postCompare' as TransformHook,
|
||||
}
|
||||
export type PlayTransformConfig = PlayTransformHooksConfig<SearchAndReplaceTerm[] | ExternalMetadataTerm>;
|
||||
export type PlayTransformOptions = PlayTransformConfig & { log?: boolean | 'all' }
|
||||
export type WhenParts<T> = PlayTransformPartsAtomic<T>;
|
||||
export type WhenConditions<T> = WhenParts<T>[];
|
||||
export type WhenConditionsConfig = WhenConditions<string>;
|
||||
@@ -6,6 +6,7 @@ import { WebhookConfig } from "./health/webhooks.js";
|
||||
import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js";
|
||||
import { SourceAIOConfig } from "./source/sources.js";
|
||||
import { CacheConfigOptions, ClientType, SourceType } from "../Atomic.js";
|
||||
import { TransformerCommonConfig } from "../../../../core/Atomic.js";
|
||||
|
||||
|
||||
export interface SourceDefaults extends CommonSourceOptions {
|
||||
@@ -66,6 +67,8 @@ export interface AIOConfig {
|
||||
debugMode?: boolean
|
||||
|
||||
cache?: CacheConfigOptions
|
||||
|
||||
transformers?: TransformerCommonConfig[]
|
||||
}
|
||||
|
||||
export interface AIOClientConfig {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { KoitoClientAIOConfig, KoitoClientConfig } from "./koito.js";
|
||||
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm.js";
|
||||
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz.js";
|
||||
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
|
||||
import { TealClientAIOConfig, TealClientConfig } from "./tealfm.js";
|
||||
import { RockSkyClientAIOConfig, RockSkyClientConfig } from "./rocksky.js";
|
||||
|
||||
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig;
|
||||
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig | RockSkyClientConfig;
|
||||
|
||||
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig;
|
||||
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig | TealClientAIOConfig | RockSkyClientAIOConfig;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlayTransformConfig, PlayTransformOptions } from "../../Atomic.js";
|
||||
import { PlayTransformConfig, PlayTransformOptions } from "../../Transform.js";
|
||||
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
import { CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions } from "./index.js";
|
||||
|
||||
export interface RockSkyData extends RequestRetryOptions{
|
||||
|
||||
/**
|
||||
* API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account
|
||||
*
|
||||
* @examples ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"]
|
||||
* */
|
||||
key: string
|
||||
|
||||
/**
|
||||
* The **fully-qualified** handle for your ATPRoto/Bluesky account, like:
|
||||
*
|
||||
* * alice.bsky.social
|
||||
* * foxxmd.com
|
||||
* * mysuer.blacksky.app
|
||||
*
|
||||
* */
|
||||
handle: string
|
||||
}
|
||||
|
||||
export interface RockSkyClientData extends RockSkyData, CommonClientData {}
|
||||
|
||||
export interface RockSkyOptions {
|
||||
/**
|
||||
* URL for the Rocksky *Listenbrainz* endpoint, if not using the default
|
||||
*
|
||||
* @examples ["https://audioscrobbler.rocksky.app"]
|
||||
* @default "https://audioscrobbler.rocksky.app"
|
||||
* */
|
||||
audioScrobblerUrl?: string
|
||||
|
||||
/**
|
||||
* URL for the Rocksky *API* endpoint, if not using the default
|
||||
*
|
||||
* @examples ["https://api.rocksky.app"]
|
||||
* @default "https://api.rocksky.app"
|
||||
* */
|
||||
apiUrl?: string
|
||||
}
|
||||
|
||||
export interface RockSkyClientOptions extends RockSkyOptions, CommonClientOptions, NowPlayingOptions {
|
||||
|
||||
}
|
||||
|
||||
export interface RockSkyClientConfig extends CommonClientConfig {
|
||||
/**
|
||||
* Should always be `client` when using RockSky as a client
|
||||
*
|
||||
* @default client
|
||||
* @examples ["client"]
|
||||
* */
|
||||
configureAs?: 'client' | 'source'
|
||||
data: RockSkyClientData
|
||||
options?: RockSkyClientOptions
|
||||
}
|
||||
|
||||
export interface RockSkyClientAIOConfig extends RockSkyClientConfig {
|
||||
type: 'rocksky'
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { RequestRetryOptions } from "../common.js"
|
||||
import { CommonClientConfig, CommonClientData, CommonClientOptions } from "./index.js"
|
||||
|
||||
export interface TealData extends RequestRetryOptions {
|
||||
/**
|
||||
* The base URI of the Multi-Scrobbler to use for ATProto OAuth
|
||||
*
|
||||
* Only include this if you want to use OAuth. The URI must be a non-IP/non-local domain using https: protocol.
|
||||
*/
|
||||
baseUri?: string
|
||||
/**
|
||||
* Identify the account to login as
|
||||
*
|
||||
* * For **App Password** Auth - your email
|
||||
* * For **Oauth** - your handle minus the @
|
||||
*/
|
||||
identifier: string
|
||||
/**
|
||||
* The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account
|
||||
*
|
||||
* This is created under https://bsky.app/settings/app-passwords
|
||||
*
|
||||
* **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT**
|
||||
*/
|
||||
appPassword?: string
|
||||
}
|
||||
|
||||
export interface TealClientData extends TealData, CommonClientData {
|
||||
|
||||
}
|
||||
export interface TealClientConfig extends CommonClientConfig {
|
||||
/**
|
||||
* Should always be `client` when using Tealfm as a client
|
||||
*
|
||||
* @default client
|
||||
* @examples ["client"]
|
||||
* */
|
||||
configureAs?: 'client' | 'source'
|
||||
data: TealClientData
|
||||
options?: TealClientOptions
|
||||
}
|
||||
|
||||
export interface TealOptions {
|
||||
}
|
||||
|
||||
|
||||
export interface TealClientOptions extends TealOptions,CommonClientOptions {
|
||||
|
||||
}
|
||||
|
||||
export interface TealClientAIOConfig extends TealClientConfig {
|
||||
type: 'tealfm'
|
||||
}
|
||||
/** https://github.com/teal-fm/piper/blob/main/service/atproto/submission.go */
|
||||
export interface ScrobbleRecord {
|
||||
$type: "fm.teal.alpha.feed.play",
|
||||
trackName: string,
|
||||
playedTime: string,
|
||||
duration?: number
|
||||
artists?: {artistName?: string, artistMbId?: string}[]
|
||||
/** Album name */
|
||||
releaseName?: string
|
||||
submissionClientAgent: string,
|
||||
musicServiceBaseDomain?: string
|
||||
// musicbrainz
|
||||
recordingMbId?: string
|
||||
releaseMbId?: string
|
||||
isrc?: string,
|
||||
[x: string]: unknown
|
||||
}
|
||||
|
||||
export interface ListRecord<T> {
|
||||
uri: string;
|
||||
cid: string;
|
||||
value: T;
|
||||
}
|
||||
export interface RecordOptions {
|
||||
web?: string;
|
||||
playId?: string;
|
||||
user?: string;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FileLogOptions, LogLevel } from "@foxxmd/logging";
|
||||
import { PlayTransformConfig, PlayTransformOptions } from "../../Atomic.js";
|
||||
|
||||
import { PlayTransformConfig, PlayTransformOptions } from "../../Transform.js";
|
||||
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
|
||||
|
||||
export interface SourceRetryOptions extends RequestRetryOptions {
|
||||
|
||||
@@ -87,6 +87,15 @@ export interface JellyApiData extends CommonSourceData {
|
||||
* @default false
|
||||
*/
|
||||
allowUnknown?: boolean
|
||||
|
||||
/**
|
||||
* HOST:PORT of the Jellyfin server that your browser will be able to access from the frontend (and thus load images and links from)
|
||||
* If unspecified it will use the normal server HOST and PORT from the `url`
|
||||
* Necessary if you are using a reverse proxy or other network configuration that prevents the frontend from accessing the server directly
|
||||
*
|
||||
* ENV: JELLYFIN_FRONTEND_URL_OVERRIDE
|
||||
* */
|
||||
frontendUrlOverride?: string
|
||||
}
|
||||
|
||||
export interface JellyApiOptions extends CommonSourceOptions {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user