mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f96035ba9 | ||
|
|
332edb5dca | ||
|
|
c5681d31eb | ||
|
|
685d70c639 | ||
|
|
848878bc5e | ||
|
|
088d063d38 | ||
|
|
4e493db987 | ||
|
|
6d017d4b27 | ||
|
|
a34066d650 | ||
|
|
0e34d63c84 | ||
|
|
9dca8ebfa0 | ||
|
|
c908aea1ae | ||
|
|
c7a58bbce6 | ||
|
|
b26f303479 | ||
|
|
13d64d2ce2 | ||
|
|
ddb7fb3d6e | ||
|
|
457967bfb2 | ||
|
|
734742ecbe | ||
|
|
26dab3870a | ||
|
|
ca9acbb089 | ||
|
|
f78d6fec1d | ||
|
|
bafb852545 | ||
|
|
a755db45b9 | ||
|
|
b9715f2159 | ||
|
|
e90dbaf691 | ||
|
|
08ac55f718 | ||
|
|
5bf831e236 | ||
|
|
1905804f2a | ||
|
|
afc931ac8c | ||
|
|
9fb032e030 | ||
|
|
09b0c96263 | ||
|
|
7c66816023 | ||
|
|
3e7e5df5de | ||
|
|
1c84a74034 | ||
|
|
09969dd9d7 | ||
|
|
63b57e1113 | ||
|
|
585677e515 | ||
|
|
57f2b5f7e5 | ||
|
|
3597735556 | ||
|
|
2442cf2286 | ||
|
|
97438e13d2 | ||
|
|
57dff9d122 | ||
|
|
963fc792a0 | ||
|
|
35a6418f38 | ||
|
|
3c0b951132 | ||
|
|
3dc9e7d8b4 | ||
|
|
ab372b8357 | ||
|
|
c79f04581f | ||
|
|
0dcd049453 | ||
|
|
7170b09586 | ||
|
|
3d893b126f | ||
|
|
fb73b8b106 | ||
|
|
cede77e3fa | ||
|
|
0572d6a308 | ||
|
|
5967e3df46 | ||
|
|
6b1ee83654 | ||
|
|
0af1496a0f | ||
|
|
52b08efd88 | ||
|
|
7a3e4e56e2 | ||
|
|
abe6638f66 | ||
|
|
eb62c2cea9 | ||
|
|
7e6eddce3e | ||
|
|
bc517e0cb0 | ||
|
|
41900b0048 | ||
|
|
9ea71b931c | ||
|
|
64f222b5b7 | ||
|
|
6c27406803 | ||
|
|
2f51e7ad99 | ||
|
|
d86dd63236 | ||
|
|
f794133915 | ||
|
|
c7d2e7acc2 | ||
|
|
2dde3840c6 | ||
|
|
4675847fec | ||
|
|
830d1a2243 | ||
|
|
8a43f0ded5 | ||
|
|
af24e0ffbe | ||
|
|
d3a70bf2c1 | ||
|
|
a62fe705f9 | ||
|
|
1c6407238c | ||
|
|
ec897b185c | ||
|
|
69e15b3797 | ||
|
|
dae4af070f | ||
|
|
beaf0f3bfd | ||
|
|
8057982184 | ||
|
|
9a5a808780 | ||
|
|
d2d5f4a705 | ||
|
|
4aa875660f | ||
|
|
5ab8f00aeb | ||
|
|
1e1b5f56ce | ||
|
|
49b9ee96f3 | ||
|
|
aef7716634 | ||
|
|
57f2e1d500 | ||
|
|
41c696992a | ||
|
|
f231d4e1e4 | ||
|
|
0efbe41b7e | ||
|
|
be9675bb10 | ||
|
|
4e45d35925 | ||
|
|
a5e293554c | ||
|
|
c76d4822fe | ||
|
|
0f8fd5cc0e | ||
|
|
8b27890254 | ||
|
|
8747c8375e | ||
|
|
50f27c110c | ||
|
|
c1f8997c01 | ||
|
|
12ed767961 | ||
|
|
335808217b | ||
|
|
b5bb7024fc | ||
|
|
ac2806dc66 | ||
|
|
9d149dab49 | ||
|
|
451404c0f9 | ||
|
|
a3f1f077ce | ||
|
|
dcd13833ae | ||
|
|
78b732b75c | ||
|
|
3152933937 | ||
|
|
6c6126bb27 | ||
|
|
ad13b7687d | ||
|
|
633e366e82 | ||
|
|
69b1786859 | ||
|
|
41839a961a | ||
|
|
ccb50c64bb | ||
|
|
d88ac1b424 | ||
|
|
c807611e55 | ||
|
|
155ec1403e | ||
|
|
ecde657c3e | ||
|
|
6aa8374f25 | ||
|
|
c2f01473fd | ||
|
|
41e607c995 | ||
|
|
fee754f15f | ||
|
|
d62d18232d |
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
*.Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
.github
|
||||
@@ -10,6 +11,7 @@ Dockerfile
|
||||
.idea
|
||||
config/*.json
|
||||
config/*.bak
|
||||
config/*.p8
|
||||
/docs
|
||||
/logs
|
||||
.flatpak-builder
|
||||
@@ -20,3 +22,4 @@ docsite/node_modules
|
||||
docsite/.docusaurus
|
||||
docsite/.cache-loader
|
||||
build
|
||||
*.secrets
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Testing GH Actions with [ACT]
|
||||
|
||||
### Test Branch Push
|
||||
|
||||
```shell
|
||||
act -W '.github/act/actTest.yml' -e '.github/act/actBranchEvent.json'
|
||||
```
|
||||
|
||||
|
||||
### Test Tag Push
|
||||
|
||||
```shell
|
||||
act -W '.github/act/actTest.yml' -e '.github/act/actTagEvent.json'
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ref": "refs/heads/master",
|
||||
"sha": "0a1a94d9ad4efa373f8d34aace5e7f0a3fff42ad"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ref": "refs/tags/0.8.0",
|
||||
"ref_name": "0.8.0",
|
||||
"sha": "0a1a94d9ad4efa373f8d34aace5e7f0a3fff42ad"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Publish Docker image to Dockerhub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'develop'
|
||||
tags:
|
||||
- '*.*.*'
|
||||
# don't trigger if just updating docs
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
# use release instead of tags once version is correctly parsed
|
||||
# https://github.com/docker/metadata-action/issues/422
|
||||
# https://github.com/docker/metadata-action/issues/240
|
||||
# release:
|
||||
# types: [ published ]
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: Build and push container images
|
||||
runs-on: ubuntu-latest
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# include:
|
||||
# - dockerfile: ./Dockerfile
|
||||
# suffix: ''
|
||||
# platforms: 'linux/amd64'
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Debug
|
||||
run: echo $JSON
|
||||
env:
|
||||
JSON: ${{ toJSON(github) }}
|
||||
|
||||
- name: Set git state to ENV
|
||||
id: vars
|
||||
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
|
||||
# short sha available under env.COMMIT_SHORT_SHA
|
||||
run: |
|
||||
calculatedSha=$(git rev-parse --short HEAD)
|
||||
branchName=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
|
||||
|
||||
# - name: Set push event short ref
|
||||
# run: |
|
||||
# shortEventRef=${github.event.push.ref:10}
|
||||
# echo "SHORT_REF=$shortEventRef" >> $GITHUB_ENV
|
||||
|
||||
- name: Check App Version
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo $APP_VERSION
|
||||
|
||||
# - name: Extract metadata (tags, labels) for Docker
|
||||
# id: meta
|
||||
# uses: docker/metadata-action@v5
|
||||
# with:
|
||||
# # generate Docker tags based on the following events/attributes
|
||||
# # https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
# tags: |
|
||||
# type=raw,value=latest,enable={{endsWith(github.ref, 'master')}},suffix=${{ matrix.suffix }}
|
||||
# type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }},suffix=${{ matrix.suffix }}
|
||||
# type=semver,pattern={{version}},suffix=${{ matrix.suffix }}
|
||||
# flavor: |
|
||||
# latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# https://github.com/docker/build-push-action/issues/1026#issue-2041857786
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{env.APP_VERSION}}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: false
|
||||
tags: mstest:latest
|
||||
#tags: ${{ steps.meta.outputs.tags }}
|
||||
#labels: ${{ steps.meta.outputs.labels }}
|
||||
#platforms: ${{ matrix.platforms }}
|
||||
@@ -1,9 +1,11 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- developDocs
|
||||
# Review gh actions docs if you want to further define triggers, paths, etc
|
||||
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on
|
||||
|
||||
@@ -12,16 +14,19 @@ jobs:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ./docsite
|
||||
- name: Build website
|
||||
env:
|
||||
ANALYTICS: ${{ vars.ANALYTICS }}
|
||||
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
|
||||
run: npm run build
|
||||
working-directory: ./docsite
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Delete Untagged Packages
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_run:
|
||||
workflows: ["Publish Docker image to Dockerhub"]
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
delete-untagged:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: dataaxiom/ghcr-cleanup-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# maybe set up for PR close later
|
||||
# https://github.com/dataaxiom/ghcr-cleanup-action?tab=readme-ov-file#delete-image-when-pull-request-is-closed
|
||||
@@ -0,0 +1,122 @@
|
||||
name: PR Workflow
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
- synchronize
|
||||
- reopened
|
||||
- opened
|
||||
branches:
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'npm'
|
||||
- name: Install dev dependencies
|
||||
run: npm ci
|
||||
- name: Build Backend
|
||||
run: 'npm run build:backend'
|
||||
- name: Test Backend
|
||||
run: npm run test
|
||||
|
||||
release-snapshot:
|
||||
name: Release snapshot
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: ./Dockerfile
|
||||
suffix: ''
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
- dockerfile: ./alpine.Dockerfile
|
||||
suffix: '-alpine'
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
foxxmd/multi-scrobbler
|
||||
ghcr.io/foxxmd/multi-scrobbler
|
||||
tags: |
|
||||
type=ref,event=pr,suffix=${{ matrix.suffix }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
env:
|
||||
APP_VERSION: ${{ format('pr{0}-{1}', github.event.number, github.event.pull_request.head.sha ) }}
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{env.APP_VERSION}}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: ${{ !env.ACT}}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
|
||||
combine-and-comment:
|
||||
name: Leave comment
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-snapshot
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
steps:
|
||||
- name: Create comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
recreate: true
|
||||
header: "pr-release"
|
||||
message: |
|
||||
#### :package: A new release has been made for this pull request.
|
||||
|
||||
To play around with this PR, pull an image:
|
||||
* `foxxmd/multi-scrobbler:pr-${{ github.event.number }}`
|
||||
* `foxxmd/multi-scrobbler:pr-${{ github.event.number }}-alpine`.
|
||||
|
||||
Images are available for x86_64 and ARM64.
|
||||
|
||||
> Latest commit: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Publish Docker image to Dockerhub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
@@ -10,29 +11,74 @@ on:
|
||||
# don't trigger if just updating docs
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.github/**'
|
||||
# use release instead of tags once version is correctly parsed
|
||||
# https://github.com/docker/metadata-action/issues/422
|
||||
# https://github.com/docker/metadata-action/issues/240
|
||||
# release:
|
||||
# types: [ published ]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
test:
|
||||
name: Run Tests
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'npm'
|
||||
- name: Install dev dependencies
|
||||
run: npm ci
|
||||
- name: Build Backend
|
||||
run: 'npm run build:backend'
|
||||
- name: Test Backend
|
||||
run: npm run test
|
||||
|
||||
push_to_registry:
|
||||
name: Build and push container images
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: ./Dockerfile
|
||||
suffix: ''
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
- dockerfile: ./alpine.Dockerfile
|
||||
suffix: '-alpine'
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: vars
|
||||
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
|
||||
# short sha available under env.COMMIT_SHORT_SHA
|
||||
run: |
|
||||
calculatedSha=$(git rev-parse --short HEAD)
|
||||
branchName=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -40,30 +86,42 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
foxxmd/multi-scrobbler
|
||||
ghcr.io/foxxmd/multi-scrobbler
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}},suffix=${{ matrix.suffix }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }},suffix=${{ matrix.suffix }}
|
||||
type=semver,pattern={{version}},suffix=${{ matrix.suffix }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' && !env.ACT}}
|
||||
# https://github.com/docker/build-push-action/issues/1026#issue-2041857786
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{env.APP_VERSION}}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: ${{ !env.ACT}}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ matrix.platforms }}
|
||||
|
||||
@@ -125,8 +125,10 @@ src/**/**.js
|
||||
src/**/**.js.map
|
||||
|
||||
*.bak
|
||||
*.p8
|
||||
.flatpak-builder
|
||||
flatpak/generated-sources.json
|
||||
*.secrets
|
||||
|
||||
build
|
||||
!setupProxy.js
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
{
|
||||
"reporter": "dot"
|
||||
"reporter": "dot",
|
||||
"extension": "ts",
|
||||
"import": "tsx/esm"
|
||||
}
|
||||
|
||||
+66
-38
@@ -1,22 +1,51 @@
|
||||
FROM lsiobase/alpine:3.18 as base
|
||||
FROM ghcr.io/linuxserver/baseimage-debian:bookworm as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
ENV NODE_VERSION 20.11.1
|
||||
|
||||
# borrowing openssl header removal trick from offical docker-node
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L8
|
||||
RUN \
|
||||
ARCH= OPENSSL_ARCH= && dpkgArch="$(dpkg --print-architecture)" \
|
||||
&& case "${dpkgArch##*-}" in \
|
||||
amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; \
|
||||
ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; \
|
||||
s390x) ARCH='s390x' OPENSSL_ARCH='linux*-s390x';; \
|
||||
arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; \
|
||||
armhf) ARCH='armv7l' OPENSSL_ARCH='linux-armv4';; \
|
||||
i386) ARCH='x86' OPENSSL_ARCH='linux-elf';; \
|
||||
*) echo "unsupported architecture"; exit 1 ;; \
|
||||
esac && \
|
||||
set -ex && \
|
||||
echo "**** install build packages ****" && \
|
||||
apk add --no-cache \
|
||||
alpine-base \
|
||||
avahi \
|
||||
avahi-tools \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
#yarn \
|
||||
openssh && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y \
|
||||
#ca-certificates \
|
||||
xz-utils \
|
||||
avahi-utils \
|
||||
curl && \
|
||||
echo "**** Fetch and install node****" && \
|
||||
# get node/npm directly from nodejs dist \
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L41
|
||||
curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
|
||||
tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr --strip-components=1 --no-same-owner && \
|
||||
rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
|
||||
ln -s /usr/bin/node /usr/bin/nodejs && \
|
||||
npm update -g npm && \
|
||||
echo "**** cleanup ****" && \
|
||||
rm -rf \
|
||||
/root/.cache \
|
||||
/tmp/*
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L49
|
||||
# Remove unused OpenSSL headers to save ~34MB
|
||||
# (does not affect arm64 issue below)
|
||||
find /usr/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; && \
|
||||
apt-get purge --auto-remove -y perl xz-utils && \
|
||||
apt-get autoclean && \
|
||||
apt-get autoremove && \
|
||||
rm -rf \
|
||||
/config/.cache \
|
||||
/root/cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/* \
|
||||
/tmp/*
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
@@ -24,27 +53,31 @@ ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
|
||||
COPY docker/root/ /
|
||||
|
||||
RUN npm install -g patch-package \
|
||||
&& chown -R root:root /usr/local/lib/node_modules/patch-package
|
||||
COPY docker/root /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base as build
|
||||
|
||||
# would very much like to use YARN for dependency management but its got show-stopping bad design for prod dependencies
|
||||
# https://github.com/yarnpkg/yarn/issues/6323 -- always downloads devDependencies -- and im not about to migrate to v2 since alpine doesn't support it yet
|
||||
|
||||
# copy dep/TS config and install dev dependencies
|
||||
#COPY --chown=abc:abc package.json tsconfig.json yarn.lock ./
|
||||
COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
|
||||
# for debugging, so the build fails faster when timing out (arm64)
|
||||
#RUN npm config set fetch-retries 1 && \
|
||||
# npm config set fetch-retry-mintimeout 5000 && \
|
||||
# npm config set fetch-retry-maxtimeout 5000
|
||||
|
||||
RUN npm install \
|
||||
# https://www.npmjs.com/package/tls-test
|
||||
# used to test that the OS supports downloading packages over HTTPS with TLS 1.2 enforced
|
||||
# -- this always succeeds but a good sanity check
|
||||
#RUN npm install -g https://tls-test.npmjs.com/tls-test-1.0.0.tgz
|
||||
|
||||
# This FAILED for node < 20 when building arm64 but not amd64 (and alpine-based Dockerfile has no issues building arm64)
|
||||
# see https://github.com/FoxxMD/multi-scrobbler/issues/126
|
||||
RUN npm ci \
|
||||
--verbose \
|
||||
# --no-audit \
|
||||
&& chown -R root:root node_modules
|
||||
#RUN yarn install
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
@@ -52,33 +85,28 @@ COPY --chown=abc:abc . /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run build && rm -rf node_modules
|
||||
#RUN yarn run build && rm -rf node_modules
|
||||
|
||||
FROM base as app
|
||||
|
||||
#COPY --chown=abc:abc package.json yarn.lock ./
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
COPY --from=base /usr/local/bin /usr/local/bin
|
||||
COPY --from=base /usr/local/lib /usr/local/lib
|
||||
COPY --from=base /usr/bin /usr/bin
|
||||
COPY --from=base /usr/lib /usr/lib
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
#
|
||||
#RUN yarn global add patch-package \
|
||||
# && yarn install --production=true \
|
||||
# && yarn global remove patch-package \
|
||||
# && yarn cache clean --mirror \
|
||||
# && chown abc:abc node_modules \
|
||||
# && rm -rf node_modules/ts-node \
|
||||
# && rm -rf node_modules/typescript
|
||||
|
||||
RUN npm install --omit=dev \
|
||||
# https://stackoverflow.com/a/63640896/1469797
|
||||
ARG APP_BUILD_VERSION
|
||||
ENV APP_VERSION=$APP_BUILD_VERSION
|
||||
|
||||
RUN npm ci --omit=dev \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf node_modules/@types
|
||||
&& rm -rf node_modules/@types \
|
||||
/root/.cache
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
|
||||
@@ -24,11 +24,12 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [JRiver](/docsite/docs/configuration/configuration.md#jriver)
|
||||
* [Kodi](/docsite/docs/configuration/configuration.md#kodi)
|
||||
* [Google Cast (Chromecast)](/docsite/docs/configuration/configuration.md#google-cast--chromecast-)
|
||||
* [Musikcube](/docsite/docs/configuration/configuration.md#muikcube)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](/docsite/docs/configuration/configuration.md#maloja)
|
||||
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm)
|
||||
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint)
|
||||
* 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)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
FROM lsiobase/alpine:3.18 as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
RUN \
|
||||
echo "**** install build packages ****" && \
|
||||
apk add --no-cache \
|
||||
avahi \
|
||||
avahi-tools \
|
||||
nodejs \
|
||||
npm \
|
||||
&& \
|
||||
echo "**** cleanup ****" && \
|
||||
rm -rf \
|
||||
/root/.cache \
|
||||
/tmp/*
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
|
||||
COPY docker/root/ /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base as build
|
||||
|
||||
# copy dep/TS config and install dev dependencies
|
||||
COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
|
||||
RUN npm ci \
|
||||
&& chown -R root:root node_modules
|
||||
#RUN yarn install
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
# need to set before build so server/client build is optimized and has constants (if needed)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run build && rm -rf node_modules
|
||||
|
||||
FROM base as app
|
||||
|
||||
#COPY --chown=abc:abc package.json yarn.lock ./
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
COPY --from=base /usr/local/bin /usr/local/bin
|
||||
COPY --from=base /usr/local/lib /usr/local/lib
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
ENV COLORED_STD=true
|
||||
|
||||
# https://stackoverflow.com/a/63640896/1469797
|
||||
ARG APP_BUILD_VERSION
|
||||
ENV APP_VERSION=$APP_BUILD_VERSION
|
||||
|
||||
RUN npm ci --omit=dev \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
EXPOSE $PORT
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 527 KiB After Width: | Height: | Size: 699 KiB |
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"debugMode": false,
|
||||
"disableWeb": false,
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 1,
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5,
|
||||
"logPayload": false,
|
||||
"logFilterFailure": "warn",
|
||||
"logPlayerState": false,
|
||||
"scrobbleThresholds": {
|
||||
"duration": 30,
|
||||
"percent": 50
|
||||
},
|
||||
"options": {
|
||||
"logPayload": false,
|
||||
"logFilterFailure": "warn",
|
||||
"logPlayerState": false
|
||||
}
|
||||
"maxPollRetries": 1,
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"type": "musikcube",
|
||||
"enable": true,
|
||||
"name": "musikcube",
|
||||
"data": {
|
||||
"password": "MY_PASSWORD"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -188,13 +188,33 @@ These options affect multi-scrobbler's behavior and are not specific to any sour
|
||||
|
||||
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify), [lastfm](#lastfm), and [deezer](#deezer) -- as well as some logging hints.
|
||||
|
||||
* Default => `http://localhost`
|
||||
* Default => `http://localhost:9078`
|
||||
* Set with [ENV](#env-based-configuration) `BASE_URL` or `baseUrl` [all-in-one configuration](#all-in-one-file-configuration)
|
||||
* If protocol is `http` or no protocol is specified MS will try to use port `9078` -- to override this explicitly set the port or use `https`
|
||||
|
||||
EX Lastfm Redirect Url is `BASE_URL:PORT/lastfm/callback` (when no other redirectUri is specified for [lastfm configuration](#lastfm))
|
||||
|
||||
* `BASE_URL=192.168.0.101` => Redirect URL is `http://192.168.0.101:9078/lastfm/callback`
|
||||
* `BASE_URL=http://my.domain.local` => Redirect URL is `http://my.domain.local:9078/lastfm/callback`
|
||||
* `BASE_URL=http://192.168.0.101/my/subfolder` => Redirect URL is `http://192.168.0.101:9078/my/subfolder/lastfm/callback`
|
||||
|
||||
* `BASE_URL=my.domain.local:80` => Redirect URL is `http://my.domain.local:80/lastfm/callback`
|
||||
* `BASE_URL=my.domain.local:9000` => Redirect URL is `http://my.domain.local:9000/lastfm/callback`
|
||||
* `BASE_URL=192.168.0.101:4000/my/subfolder` => Redirect URL is `http://192.168.0.101:4000/my/subfolder/lastfm/callback`
|
||||
* `BASE_URL=https://192.168.0.101` => Redirect URL is `https://192.168.0.101:443/lastfm/callback`
|
||||
|
||||
EX: Lastfm Redirect Url is `BASE_URL:PORT/lastfm/callback` -- Set `BASE_URL=http://192.168.0.101` => Redirect URL is `http://192.168.0.101:9078/lastfm/callback` (when no other redirectUri is specified for [lastfm configuration](#lastfm))
|
||||
|
||||
Useful when running with [docker](../installation/installation.md#docker) so that you do not need to specify redirect URLs for each configuration.
|
||||
|
||||
#### Disable Web
|
||||
|
||||
If you do not need the dashboard and/or ingress sources, or have security concerns about ingress and cannot control their hosting environment, the web server and API can be disabled. Note that any **ingress-based sources will be unusable** (Plex, Jellyfin, Tautulli, etc...) if this is disabled.
|
||||
|
||||
Disable using either:
|
||||
|
||||
* ENV `DISABLE_WEB=true`
|
||||
* In [All-in-One File](#all-in-one-file-configuration) use the top-level property `"disableWeb": true`
|
||||
|
||||
# Source Configurations
|
||||
|
||||
## [Spotify](https://www.spotify.com)
|
||||
@@ -826,6 +846,38 @@ Note: [Manually configuring cast device connections](#connecting-devices) is onl
|
||||
|
||||
See [`chromecast.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/chromecast.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FChromecastSourceConfig/%23%2Fdefinitions%2FChromecastData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Musikcube](https://musikcube.com)
|
||||
|
||||
In order to use Musikcube 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`
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|-----------------------|--------------------------------------|
|
||||
| `MC_URL` | No | `ws://localhost:7905` | Use port set for **metadata server** |
|
||||
| `MC_PASSWORD` | Yes | | |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`musikcube.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/chromecast.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMuikcubeSourceConfig/%23%2Fdefinitions%2FMuikcubeData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
# Client Configurations
|
||||
|
||||
## [Maloja](https://github.com/krateng/maloja)
|
||||
@@ -974,6 +1026,25 @@ EX
|
||||
}
|
||||
```
|
||||
|
||||
### [Apprise](https://github.com/caronc/apprise-api)
|
||||
|
||||
Refer to the [config schema for AppriseConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FAppriseConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
multi-scrobbler supports [stateless](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) and [persistent storage](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) endpoints as well as [tags](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging)/
|
||||
|
||||
EX
|
||||
|
||||
```json5
|
||||
{
|
||||
"type": "apprise",
|
||||
"name": "MyAppriseFriendlyNameForLogs",
|
||||
"host": "http://192.168.0.100:8080",
|
||||
"urls": ["gotify://192.168.0.101:8070/MyToken"], // stateless endpoints
|
||||
"keys": ["e90b20526808373353afad7fb98a201198c0c3e0555bea19f182df3388af7b17"], //persistent storage endpoints
|
||||
"tags": ["my","optional","tags"]
|
||||
}
|
||||
```
|
||||
|
||||
## Health Endpoint
|
||||
|
||||
An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/health`
|
||||
|
||||
@@ -42,6 +42,8 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
},
|
||||
"options": {
|
||||
"maxRequestRetries": 2, // override default max retries because spotify can...spotty
|
||||
}
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -22,6 +22,25 @@ npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Rollup build error
|
||||
|
||||
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
|
||||
|
||||
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
|
||||
|
||||
```json
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
}
|
||||
"vite": {
|
||||
"rollup": "npm:@rollup/wasm-node@^4.9.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
|
||||
|
||||
### Usage Examples
|
||||
|
||||
* The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
@@ -46,6 +65,11 @@ You must have [Flatpak](https://flatpak.org/) installed on your system.
|
||||
flatpak install flathub io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
**Note:** Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
|
||||
|
||||
* to use a [Docker](#docker) installation if possible or
|
||||
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
|
||||
|
||||
@@ -33,7 +33,8 @@ const config = {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
},
|
||||
|
||||
scripts: [
|
||||
],
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
@@ -156,4 +157,14 @@ const config = {
|
||||
}),
|
||||
};
|
||||
|
||||
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
|
||||
const script = {
|
||||
src: process.env.ANALYTICS,
|
||||
}
|
||||
if (process.env.ANALYTICS_DOMAIN !== undefined && process.env.ANALYTICS_DOMAIN !== '') {
|
||||
script['data-domain'] = process.env.ANALYTICS_DOMAIN;
|
||||
}
|
||||
config.scripts.push(script)
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -27,11 +27,12 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [JRiver](docs/configuration#jriver)
|
||||
* [Kodi](docs/configuration#kodi)
|
||||
* [Google Cast (Chromecast)](/docs/configuration#google-cast--chromecast-)
|
||||
* [Musikcube](docs/configuration#musikcube)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
|
||||
* 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)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 527 KiB After Width: | Height: | Size: 699 KiB |
@@ -0,0 +1,43 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import tsEslint from 'typescript-eslint';
|
||||
import arrow from 'eslint-plugin-prefer-arrow-functions';
|
||||
|
||||
export default tsEslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tsEslint.configs.recommended,
|
||||
// use to enable typed linting (way more errors) https://typescript-eslint.io/linting/typed-linting
|
||||
/* ...tsEslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigDirName: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},*/
|
||||
{
|
||||
files: ['src/backend/**/*.ts'],
|
||||
ignores: ['eslint.config.js'],
|
||||
plugins: {
|
||||
"prefer-arrow-functions": arrow
|
||||
},
|
||||
rules: {
|
||||
'no-useless-catch': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
"prefer-arrow-functions/prefer-arrow-functions": [
|
||||
"warn",
|
||||
{
|
||||
"allowNamedFunctions": false,
|
||||
"classPropertiesAllowed": false,
|
||||
"disallowPrototype": false,
|
||||
"returnStyle": "unchanged",
|
||||
"singleReturnOnly": false
|
||||
}
|
||||
],
|
||||
"arrow-body-style": ["warn", "as-needed"],
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -3,14 +3,18 @@
|
||||
<id>io.github.foxxmd.multiscrobbler</id>
|
||||
<provides>
|
||||
<id>io.github.foxxmd.multiscrobbler.desktop</id>
|
||||
<binary>multiscrobbler</binary>
|
||||
</provides>
|
||||
<launchable type="desktop-id">io.github.foxxmd.multiscrobbler.desktop</launchable>
|
||||
<name>multi-scrobbler</name>
|
||||
<summary>Scrobbles music from many sources to many clients</summary>
|
||||
<summary>Scrobbles music from many sources</summary>
|
||||
<url type="homepage">https://foxxmd.github.io/multi-scrobbler</url>
|
||||
<url type="help">https://foxxmd.github.io/multi-scrobbler/docs/configuration</url>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<developer id="tld.vendor">
|
||||
<name>FoxxMD</name>
|
||||
</developer>
|
||||
<description>
|
||||
<p>
|
||||
Track your music listening history from many different sources:
|
||||
@@ -29,6 +33,7 @@
|
||||
<li>Youtube Music</li>
|
||||
<li>MPRIS (linux desktop)</li>
|
||||
<li>Google Cast (Chromecast)</li>
|
||||
<li>Musikcube</li>
|
||||
</ul>
|
||||
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
|
||||
<ul>
|
||||
@@ -45,6 +50,10 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.8.0" date="2024-07-08"/>
|
||||
<release version="0.7.1" date="2024-04-11"/>
|
||||
<release version="0.7.0" date="2024-03-26"/>
|
||||
<release version="0.6.5" date="2024-02-05"/>
|
||||
<release version="0.6.4" date="2024-01-29"/>
|
||||
<release version="0.6.3" date="2024-01-10"/>
|
||||
<release version="0.6.2" date="2023-11-29"/>
|
||||
|
||||
@@ -30,7 +30,7 @@ modules:
|
||||
- mkdir -p /app/bin /app/lib /app/lib/dist /app/lib/src /app/lib/assets
|
||||
- cp -a /usr/lib/sdk/node18/bin/{node,npm} /app/bin
|
||||
- cp -a /usr/lib/sdk/node18/lib/* /app/lib
|
||||
- rm -r /app/lib/node_modules/npm/{docs,man}
|
||||
- rm -r /app/lib/node_modules/npm/{docs,man} # remove this when updating to newer runtime-version
|
||||
|
||||
# remove dev dependencies
|
||||
- npm prune --production
|
||||
@@ -38,6 +38,10 @@ modules:
|
||||
# copy node_modules needed to run app
|
||||
- cp -r node_modules/. /app/lib/node_modules
|
||||
|
||||
# even if not using package.json for scripts it must be present
|
||||
# so that tsx/node runs as esm (needs to see "type": "module" in file)
|
||||
- cp package.json /app/lib/package.json
|
||||
|
||||
# copy app files to runtime dir
|
||||
- cp -r dist/. /app/lib/dist
|
||||
- cp -r src/. /app/lib/src
|
||||
@@ -59,11 +63,12 @@ modules:
|
||||
subdir: main
|
||||
sources:
|
||||
# use for official releases
|
||||
- type: git
|
||||
url: https://github.com/FoxxMD/multi-scrobbler
|
||||
tag: 0.6.3
|
||||
commit: 4bd996eb466137ffc5c3f48305afabde03720525
|
||||
dest: main
|
||||
# - type: git
|
||||
# url: https://github.com/FoxxMD/multi-scrobbler
|
||||
# tag: 0.6.3
|
||||
# commit: 4bd996eb466137ffc5c3f48305afabde03720525
|
||||
# dest: main
|
||||
|
||||
# use if developing locally
|
||||
# - type: dir
|
||||
# path: /home/yourUser/multi-scrobbler
|
||||
|
||||
Generated
+2982
-3728
File diff suppressed because it is too large
Load Diff
+31
-33
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.6.4",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
@@ -13,11 +13,14 @@
|
||||
"typedoc": "typedoc",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "npm run -s test:backend",
|
||||
"test:backend": "NODE_OPTIONS='--loader tsx' mocha --extension ts --reporter spec --recursive src/backend/tests/**/*.test.ts",
|
||||
"test:backend": "mocha --reporter spec --recursive src/backend/tests/**/*.test.ts",
|
||||
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend",
|
||||
"dev": "APP_VERSION=$npm_package_version nodemon -w src/backend -x tsx src/backend/index.ts",
|
||||
"start": "APP_VERSION=$npm_package_version NODE_ENV=production tsx src/backend/index.ts",
|
||||
"build": "APP_VERSION=$npm_package_version vite build"
|
||||
"dev": "nodemon -w src/backend -x tsx src/backend/index.ts",
|
||||
"start": "NODE_ENV=production tsx src/backend/index.ts",
|
||||
"build:frontend": "APP_VERSION=$npm_package_version vite build",
|
||||
"build:backend": "tsc -p src/backend",
|
||||
"build": "npm run -s build:backend && npm run -s build:frontend",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -43,12 +46,15 @@
|
||||
"dependencies": {
|
||||
"@astronautlabs/mdns": "^1.0.7",
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"@curvenote/ansi-to-react": "^7.0.0",
|
||||
"@donedeal0/superdiff": "^1.1.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@foxxmd/chromecast-client": "^1.0.1",
|
||||
"@foxxmd/string-sameness": "^0.2.0",
|
||||
"@foxxmd/winston": "3.3.31",
|
||||
"@foxxmd/chromecast-client": "^1.0.4",
|
||||
"@foxxmd/get-version": "^0.0.3",
|
||||
"@foxxmd/logging": "^0.1.14",
|
||||
"@foxxmd/string-sameness": "^0.4.0",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
@@ -64,52 +70,47 @@
|
||||
"common-tags": "^1.8.2",
|
||||
"compare-versions": "^4.1.2",
|
||||
"concat-stream": "^2.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.10.4",
|
||||
"dbus-next": "0.10.2",
|
||||
"dbus-ts": "^0.0.7",
|
||||
"dotenv": "^10.0.0",
|
||||
"es6-error": "^4.1.1",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.2",
|
||||
"fixed-size-list": "^0.3.0",
|
||||
"formidable": "^2.1",
|
||||
"gotify": "^1.1.0",
|
||||
"iso-websocket": "^0.2.0",
|
||||
"iti": "^0.6.0",
|
||||
"json5": "^2.2.3",
|
||||
"kodi-api": "^0.2.1",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"mopidy": "^1.3.0",
|
||||
"nanoid": "^3.3.1",
|
||||
"normalize-url": "^6.1.0",
|
||||
"ntfy": "1.0.5",
|
||||
"normalize-url": "^8.0.1",
|
||||
"ntfy": "^1.5.4",
|
||||
"object-hash": "^3.0.0",
|
||||
"p-event": "^4.2.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-deezer": "^0.2.0",
|
||||
"pony-cause": "^1.1.1",
|
||||
"patch-package": "^8.0.0",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"sass": "^1.70.0",
|
||||
"spotify-web-api-node": "^5.0.2",
|
||||
"superagent": "^8.0.9",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"triple-beam": "^1.3.0",
|
||||
"tsx": "^4.7.0",
|
||||
"vite-express": "^0.13.0",
|
||||
"wait-on": "^7.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"winston-daily-rotate-file": "^4.5.0",
|
||||
"winston-duplex": "0.1.3",
|
||||
"winston-null": "^2.0.0",
|
||||
"winston-transport": "^4.4.0",
|
||||
"xml2js": "^0.4.23",
|
||||
"vite-express": "^0.16.0",
|
||||
"xml2js": "0.5.0",
|
||||
"youtube-music-ts-api": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
"@eslint/js": "^8.56.0",
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
@@ -121,6 +122,7 @@
|
||||
"@types/clone": "^2.1.2",
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/formidable": "^2.0.5",
|
||||
@@ -135,26 +137,22 @@
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^4.1.16",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"clone": "^2.1.2",
|
||||
"concurrently": "^8.2.0",
|
||||
"jscodeshift": "^0.15.0",
|
||||
"mocha": "10.1.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"mocha": "^10.3.0",
|
||||
"msw": "^2.1.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"nyc": "^15.1.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"ts-essentials": "^9.1.2",
|
||||
"typedoc": "^0.25",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^7.0.1",
|
||||
"typescript-json-schema": "~0.55",
|
||||
"vite": "^5.0.11",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"wtfnode": "^0.9.1"
|
||||
"vite": "^5.2.12"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
diff --git a/node_modules/lastfm-node-client/lib/ApiRequest.js b/node_modules/lastfm-node-client/lib/ApiRequest.js
|
||||
index ecd39d0..c8e1cc5 100644
|
||||
--- a/node_modules/lastfm-node-client/lib/ApiRequest.js
|
||||
+++ b/node_modules/lastfm-node-client/lib/ApiRequest.js
|
||||
@@ -91,7 +91,7 @@ class ApiRequest {
|
||||
const paramsStr = querystring.stringify(paramsObj);
|
||||
const options = {
|
||||
hostname: "ws.audioscrobbler.com",
|
||||
- path: "/2.0"
|
||||
+ path: "/2.0",
|
||||
};
|
||||
|
||||
if (method === "POST") {
|
||||
@@ -113,7 +113,11 @@ class ApiRequest {
|
||||
httpResponse.on("data", chunk => data += chunk);
|
||||
httpResponse.on("end", () => resolve(data));
|
||||
httpResponse.on("error", err => reject(err));
|
||||
- });
|
||||
+ })
|
||||
+ // stop waiting for request if it takes longer than 3 seconds
|
||||
+ .setTimeout(3000, () => {
|
||||
+ httpRequest.destroy(new Error('ETIMEDOUT - socket hang up'));
|
||||
+ });
|
||||
|
||||
httpRequest.on("error", err => reject(err));
|
||||
|
||||
diff --git a/node_modules/lastfm-node-client/lib/LastFm.js b/node_modules/lastfm-node-client/lib/LastFm.js
|
||||
index 2393a4d..75bdf37 100644
|
||||
--- a/node_modules/lastfm-node-client/lib/LastFm.js
|
||||
+++ b/node_modules/lastfm-node-client/lib/LastFm.js
|
||||
@@ -1045,7 +1045,6 @@ class LastFm {
|
||||
* @param {callback} [callback]
|
||||
* @returns {(Promise|LastFm)}
|
||||
*/
|
||||
-
|
||||
trackUpdateNowPlaying(params, callback) {
|
||||
const apiRequest = new ApiRequest()
|
||||
.set(params)
|
||||
@@ -0,0 +1,26 @@
|
||||
diff --git a/node_modules/typescript-json-schema/dist/typescript-json-schema.js b/node_modules/typescript-json-schema/dist/typescript-json-schema.js
|
||||
index 23cc6d1..221adc2 100644
|
||||
--- a/node_modules/typescript-json-schema/dist/typescript-json-schema.js
|
||||
+++ b/node_modules/typescript-json-schema/dist/typescript-json-schema.js
|
||||
@@ -55,7 +55,7 @@ var crypto_1 = require("crypto");
|
||||
var ts = require("typescript");
|
||||
var path_equal_1 = require("path-equal");
|
||||
var vm = require("vm");
|
||||
-var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g;
|
||||
+var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g;
|
||||
var REGEX_TSCONFIG_NAME = /^.*\.json$/;
|
||||
var REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
|
||||
var REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
|
||||
diff --git a/node_modules/typescript-json-schema/typescript-json-schema.ts b/node_modules/typescript-json-schema/typescript-json-schema.ts
|
||||
index 5908567..c188383 100644
|
||||
--- a/node_modules/typescript-json-schema/typescript-json-schema.ts
|
||||
+++ b/node_modules/typescript-json-schema/typescript-json-schema.ts
|
||||
@@ -9,7 +9,7 @@ export { Program, CompilerOptions, Symbol } from "typescript";
|
||||
|
||||
const vm = require("vm");
|
||||
|
||||
-const REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g;
|
||||
+const REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g;
|
||||
const REGEX_TSCONFIG_NAME = /^.*\.json$/;
|
||||
const REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
|
||||
const REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
|
||||
@@ -1,12 +0,0 @@
|
||||
diff --git a/node_modules/winston-daily-rotate-file/index.js b/node_modules/winston-daily-rotate-file/index.js
|
||||
index c818660..a8d3040 100644
|
||||
--- a/node_modules/winston-daily-rotate-file/index.js
|
||||
+++ b/node_modules/winston-daily-rotate-file/index.js
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
-var winston = require('winston');
|
||||
+var winston = require('@foxxmd/winston');
|
||||
var DailyRotateFile = require('./daily-rotate-file');
|
||||
|
||||
winston.transports.DailyRotateFile = DailyRotateFile;
|
||||
@@ -1,10 +0,0 @@
|
||||
diff --git a/node_modules/winston-null/index.js b/node_modules/winston-null/index.js
|
||||
index 1a8a4ff..5088d6c 100644
|
||||
--- a/node_modules/winston-null/index.js
|
||||
+++ b/node_modules/winston-null/index.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-const winston = require('winston');
|
||||
+const winston = require('@foxxmd/winston');
|
||||
const compat = require('winston-compat');
|
||||
const semver = require('semver');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,149 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
|
||||
import { hasUpstreamError } from "./errors/UpstreamError.js";
|
||||
|
||||
export default abstract class AbstractComponent {
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authed: boolean = false;
|
||||
authFailure?: boolean;
|
||||
|
||||
buildOK?: boolean | null;
|
||||
connectionOK?: boolean | null;
|
||||
|
||||
initializing: boolean = false;
|
||||
|
||||
logger: Logger;
|
||||
|
||||
initialize = async () => {
|
||||
this.logger.debug('Attempting to initialize...');
|
||||
try {
|
||||
this.initializing = true;
|
||||
await this.buildInitData();
|
||||
await this.checkConnection();
|
||||
await this.testAuth();
|
||||
this.logger.info('Fully Initialized!');
|
||||
try {
|
||||
await this.postInitialize();
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error('Error occurred during post-initialization hook but was caught', {cause: e}));
|
||||
}
|
||||
return true;
|
||||
} catch(e) {
|
||||
this.logger.error(new Error('Initialization failed', {cause: e}));
|
||||
return false;
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async buildInitData() {
|
||||
if(this.buildOK) {
|
||||
return;
|
||||
}
|
||||
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 Error('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() {
|
||||
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 Error('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
|
||||
|
||||
protected doAuthentication = async (): Promise<boolean> => this.authed
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async () => {
|
||||
if(!this.requiresAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
this.logger.error(new Error(`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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ResponseError} from "superagent";
|
||||
import { ResponseError } from "superagent";
|
||||
|
||||
export const isSuperAgentResponseError = (e: any): e is ResponseError => {
|
||||
return typeof e === 'object'
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { findCauseByFunc } from "../../utils/ErrorUtils.js";
|
||||
import { isArbitraryObject } from "../infrastructure/Atomic.js";
|
||||
import ErrnoException = NodeJS.ErrnoException;
|
||||
import { findCauseByFunc } from "../../utils.js";
|
||||
|
||||
export type NodeNetworkErrorCode = 'ENOTFOUND' | 'ETIMEDOUT' | 'EAI_AGAIN' | 'ECONNRESET' | 'ECONNREFUSED' | 'ERRADDRINUSE' | 'EADDRNOTAVAIL' | 'ECONNABORTED' | 'EHOSTUNREACH';
|
||||
export const NETWORK_ERROR_CODES = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
|
||||
export const NETWORK_ERROR_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
|
||||
|
||||
export const NETWORK_ERROR_FAILURE_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
|
||||
|
||||
export const isErrnoException = (error: unknown): error is ErrnoException => {
|
||||
return isArbitraryObject(error) &&
|
||||
@@ -19,7 +21,7 @@ export interface NodeNetworkException extends ErrnoException {
|
||||
}
|
||||
|
||||
export const isNodeNetworkException = (error: unknown): error is NodeNetworkException => {
|
||||
return isErrnoException(error) && NETWORK_ERROR_CODES.includes(error.code);
|
||||
return isErrnoException(error) && NETWORK_ERROR_CODES.includes(error.code as NodeNetworkErrorCode);
|
||||
}
|
||||
|
||||
export const hasNodeNetworkException = (error: unknown): boolean => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { findCauseByFunc } from "../../utils.js";
|
||||
import {Response} from 'superagent';
|
||||
import { Response } from 'superagent';
|
||||
|
||||
export class UpstreamError<T = undefined> extends ErrorWithCause<T> {
|
||||
import { findCauseByFunc } from "../../utils/ErrorUtils.js";
|
||||
|
||||
export class UpstreamError<T = undefined> extends Error {
|
||||
|
||||
showStopper: boolean = false;
|
||||
response?: Response
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {Dayjs} from "dayjs";
|
||||
import {FixedSizeList} from 'fixed-size-list';
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { Logger } from '@foxxmd/logging';
|
||||
import { Dayjs } from "dayjs";
|
||||
import { Request, Response } from "express";
|
||||
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { PlayMeta, PlayObject } from "../../../core/Atomic.js";
|
||||
import TupleMap from "../TupleMap.js";
|
||||
import {Request, Response} from "express";
|
||||
import {NextFunction, ParamsDictionary, Query} from "express-serve-static-core";
|
||||
import { LogLevel, logLevels, PlayMeta, PlayObject } from "../../../core/Atomic.js";
|
||||
|
||||
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi' | 'webscrobbler' | 'chromecast';
|
||||
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi', 'webscrobbler', 'chromecast'];
|
||||
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi' | 'webscrobbler' | 'chromecast' | 'musikcube';
|
||||
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi', 'webscrobbler', 'chromecast', 'musikcube'];
|
||||
|
||||
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
|
||||
|
||||
@@ -27,7 +27,7 @@ export const READY: ReadyState = 2;
|
||||
export const readyStates: ReadyState[] = [NOT_READY, GETTING_READY, READY];
|
||||
|
||||
export interface InternalConfig {
|
||||
localUrl: string
|
||||
localUrl: URL
|
||||
configDir: string
|
||||
|
||||
logger: Logger
|
||||
@@ -105,45 +105,6 @@ export interface RemoteIdentityParts {
|
||||
agent: string | undefined
|
||||
}
|
||||
|
||||
export interface LogConfig {
|
||||
level?: string
|
||||
file?: string | false
|
||||
stream?: string
|
||||
console?: string | false
|
||||
}
|
||||
|
||||
export interface LogOptions {
|
||||
/**
|
||||
* Specify the minimum log level for all log outputs without their own level specified.
|
||||
*
|
||||
* Defaults to env `LOG_LEVEL` or `info` if not specified.
|
||||
*
|
||||
* @default 'info'
|
||||
* */
|
||||
level?: LogLevel
|
||||
/**
|
||||
* Specify the minimum log level to output to rotating files. If `false` no log files will be created.
|
||||
* */
|
||||
file?: LogLevel | false
|
||||
/**
|
||||
* Specify the minimum log level streamed to the UI
|
||||
* */
|
||||
stream?: LogLevel
|
||||
/**
|
||||
* Specify the minimum log level streamed to the console (or docker container)
|
||||
* */
|
||||
console?: LogLevel | false
|
||||
}
|
||||
|
||||
export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
|
||||
return Object.entries(obj).every(([key, val]) => {
|
||||
if(key !== 'file') {
|
||||
return val === undefined || logLevels.includes(val.toLocaleLowerCase());
|
||||
}
|
||||
return val === undefined || val === false || logLevels.includes(val.toLocaleLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/40510611/typescript-interface-require-one-of-two-properties-to-exist#comment116238286_49725198
|
||||
export type RequireAtLeastOne<T, R extends keyof T = keyof T> = Omit<T, R> & { [ P in R ] : Required<Pick<T, P>> & Partial<Omit<T, P>> }[R];
|
||||
|
||||
@@ -205,7 +166,7 @@ export interface numberFormatOptions {
|
||||
}
|
||||
}
|
||||
|
||||
export const DELIMITERS = [',','&','\/','\\'];
|
||||
export const DELIMITERS = [',','&','/','\\'];
|
||||
|
||||
export const ARTIST_WEIGHT = 0.3;
|
||||
export const TITLE_WEIGHT = 0.4;
|
||||
@@ -232,3 +193,7 @@ export interface MdnsDeviceInfo {
|
||||
type: string
|
||||
addresses: string[]
|
||||
}
|
||||
|
||||
export type AbstractApiOptions = Record<any, any> & { logger: Logger }
|
||||
|
||||
export type keyOmit<T, U extends keyof any> = T & { [P in U]?: never }
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { CommonSourceOptions, ScrobbleThresholds, SourceRetryOptions } from "./source/index.js";
|
||||
import { RequestRetryOptions } from "./common.js";
|
||||
import { SourceAIOConfig } from "./source/sources.js";
|
||||
import { LogOptions } from "@foxxmd/logging";
|
||||
import { ClientAIOConfig } from "./client/clients.js";
|
||||
import { CommonClientOptions } from "./client/index.js";
|
||||
import { RequestRetryOptions } from "./common.js";
|
||||
import { WebhookConfig } from "./health/webhooks.js";
|
||||
import { LogOptions } from "../Atomic.js";
|
||||
import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js";
|
||||
import { SourceAIOConfig } from "./source/sources.js";
|
||||
|
||||
export interface SourceDefaults extends SourceRetryOptions {
|
||||
/**
|
||||
* Set thresholds for when multi-scrobbler should consider a tracked play to be "scrobbable". If both duration and percent are defined then if either condition is met the track is scrobbled.
|
||||
* */
|
||||
scrobbleThresholds?: ScrobbleThresholds
|
||||
options?: CommonSourceOptions
|
||||
|
||||
export interface SourceDefaults extends CommonSourceOptions {
|
||||
}
|
||||
|
||||
export interface ClientDefaults extends CommonClientOptions {
|
||||
}
|
||||
|
||||
export interface AIOConfig {
|
||||
sourceDefaults?: SourceDefaults
|
||||
clientDefaults?: RequestRetryOptions
|
||||
clientDefaults?: ClientDefaults
|
||||
sources?: SourceAIOConfig[]
|
||||
clients?: ClientAIOConfig[]
|
||||
|
||||
@@ -44,6 +45,13 @@ export interface AIOConfig {
|
||||
|
||||
logging?: LogOptions
|
||||
|
||||
/**
|
||||
* Disable web server from running/listening on port.
|
||||
*
|
||||
* This will also make any ingress sources (Plex, Jellyfin, Tautulli, etc...) unusable
|
||||
* */
|
||||
disableWeb?: boolean
|
||||
|
||||
/**
|
||||
* Enables ALL relevant logging and debug options for all sources/clients, when none are defined.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
|
||||
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm.js";
|
||||
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz.js";
|
||||
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
|
||||
|
||||
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonConfig, CommonData } from "../common.js";
|
||||
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
|
||||
|
||||
/**
|
||||
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
|
||||
@@ -28,50 +28,41 @@ export interface MatchLoggingOptions {
|
||||
}
|
||||
|
||||
export interface CommonClientData extends CommonData {
|
||||
}
|
||||
|
||||
export interface CommonClientOptions extends RequestRetryOptions {
|
||||
/**
|
||||
* default # of http request retries a client can make before error is thrown.
|
||||
* Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
refreshEnabled?: boolean
|
||||
|
||||
/**
|
||||
* The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.
|
||||
* */
|
||||
refreshInitialCount?: number
|
||||
/**
|
||||
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
checkExistingScrobbles?: boolean
|
||||
/**
|
||||
* Options used for increasing verbosity of logging in MS (used for debugging)
|
||||
* */
|
||||
verbose?: {
|
||||
|
||||
match?: MatchLoggingOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of times MS should automatically retry scrobbles in dead letter queue
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
maxRequestRetries?: number
|
||||
/**
|
||||
* default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).
|
||||
*
|
||||
* @default 1.5
|
||||
* @examples [1.5]
|
||||
* */
|
||||
retryMultiplier?: number
|
||||
|
||||
options?: {
|
||||
/**
|
||||
* Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
refreshEnabled?: boolean
|
||||
/**
|
||||
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
checkExistingScrobbles?: boolean
|
||||
/**
|
||||
* Options used for increasing verbosity of logging in MS (used for debugging)
|
||||
* */
|
||||
verbose?: {
|
||||
|
||||
match?: MatchLoggingOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of times MS should automatically retry scrobbles in dead letter queue
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
deadLetterRetries?: number
|
||||
}
|
||||
deadLetterRetries?: number
|
||||
}
|
||||
|
||||
export interface CommonClientConfig extends CommonConfig {
|
||||
@@ -85,4 +76,5 @@ export interface CommonClientConfig extends CommonConfig {
|
||||
* Specific data required to configure this client
|
||||
* */
|
||||
data?: CommonClientData
|
||||
options?: CommonClientOptions
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
|
||||
export interface LastfmData extends RequestRetryOptions {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
|
||||
export interface ListenBrainzData extends RequestRetryOptions{
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
|
||||
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { keyOmit } from "../Atomic.js";
|
||||
|
||||
export interface CommonConfig {
|
||||
name?: string
|
||||
data?: CommonData
|
||||
@@ -10,14 +12,11 @@ export interface CommonConfig {
|
||||
enable?: boolean
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
[key: string]: any
|
||||
options?: Record<string, any>
|
||||
}
|
||||
export type CommonData = keyOmit<{ [key: string]: any }, "options">
|
||||
|
||||
export interface RequestRetryOptions {
|
||||
/**
|
||||
* default # of http request retries a source can make before error is thrown
|
||||
* default # of http request retries a source/client can make before error is thrown
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export interface WebhookPayload {
|
||||
title?: string
|
||||
message: string
|
||||
priority: 'info' | 'warn' | 'error'
|
||||
priority: Priority
|
||||
}
|
||||
|
||||
export type Priority = 'info' | 'warn' | 'error';
|
||||
|
||||
export interface PrioritiesConfig {
|
||||
/**
|
||||
* @examples [5]
|
||||
@@ -28,7 +30,7 @@ export interface CommonWebhookConfig {
|
||||
*
|
||||
* @examples ["gotify"]
|
||||
* */
|
||||
type: 'gotify' | 'ntfy'
|
||||
type: 'gotify' | 'ntfy' | 'apprise'
|
||||
/**
|
||||
* A friendly name used to identify webhook config in logs
|
||||
* */
|
||||
@@ -90,4 +92,30 @@ export interface NtfyConfig extends CommonWebhookConfig {
|
||||
priorities?: PrioritiesConfig
|
||||
}
|
||||
|
||||
export type WebhookConfig = GotifyConfig | NtfyConfig;
|
||||
export interface AppriseConfig extends CommonWebhookConfig {
|
||||
/**
|
||||
* The URL of the apprise-api server
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
host: string
|
||||
|
||||
/**
|
||||
* If using [Stateless Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) the Apprise config URL(s) to send
|
||||
* */
|
||||
urls?: string | string[]
|
||||
|
||||
/**
|
||||
* If using [Persistent Store Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) the Configuration ID(s) to send to
|
||||
*
|
||||
* Note: If multiple keys are defined then MS will attempt to POST to each one individually
|
||||
* */
|
||||
keys?: string | string[]
|
||||
|
||||
/**
|
||||
* Optional [tag(s)](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging) to send in the notification payload
|
||||
* */
|
||||
tags?: string | string[]
|
||||
}
|
||||
|
||||
export type WebhookConfig = GotifyConfig | NtfyConfig | AppriseConfig;
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ScrobbleThresholds {
|
||||
percent?: number | null
|
||||
}
|
||||
|
||||
export interface CommonSourceOptions {
|
||||
export interface CommonSourceOptions extends SourceRetryOptions {
|
||||
/**
|
||||
* If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)
|
||||
* then setting this option to true will make MS log the payload JSON to DEBUG output
|
||||
@@ -83,13 +83,23 @@ export interface CommonSourceOptions {
|
||||
* @examples [true, false]
|
||||
* */
|
||||
scrobbleBacklog?: boolean
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData, SourceRetryOptions {
|
||||
/**
|
||||
* Set thresholds for when multi-scrobbler should consider a tracked play to be "scrobbable". If both duration and percent are defined then if either condition is met the track is scrobbled.
|
||||
* */
|
||||
scrobbleThresholds?: ScrobbleThresholds
|
||||
|
||||
/**
|
||||
* The number of listens to fetch when scrobbling from backlog
|
||||
*
|
||||
* * Only applies if this source supports fetching a listen history
|
||||
* * If not specified it defaults to the maximum number of listens the source API supports
|
||||
* */
|
||||
scrobbleBacklogCount?: number
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData {
|
||||
|
||||
}
|
||||
|
||||
export interface CommonSourceConfig extends CommonConfig {
|
||||
|
||||
@@ -17,33 +17,6 @@ export interface JellyData extends CommonSourceData {
|
||||
* @examples [["MyServerName1"]]
|
||||
* */
|
||||
servers?: string | string[]
|
||||
|
||||
/**
|
||||
* Additional options for jellyfin logging and tuning
|
||||
* */
|
||||
options?: {
|
||||
/**
|
||||
* Log raw Jellyfin webhook payload to debug
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
logPayload?: boolean
|
||||
|
||||
/**
|
||||
* How MS should log when a Jellyfin event fails a defined filter (users/servers)
|
||||
*
|
||||
* * `false` => do not log
|
||||
* * `debug` => log to DEBUG level
|
||||
* * `warn` => log to WARN level (default)
|
||||
*
|
||||
* Hint: This is useful if you are sure this source is setup correctly and you have multiple other Jellyfin sources. Set to `debug` or `false` to reduce log noise.
|
||||
*
|
||||
* @default warn
|
||||
* @examples ["warn"]
|
||||
* */
|
||||
logFilterFailure?: false | 'debug' | 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
export interface JellySourceConfig extends CommonSourceConfig {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface JRiverData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
|
||||
export interface KodiData extends CommonSourceData, PollingOptions {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LastfmData } from "../client/lastfm.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface LastFmSourceData extends CommonSourceData, PollingOptions, LastfmData{}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { ListenBrainzData } from "../client/listenbrainz.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData, PollingOptions {
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface MopidyData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export const PLAYBACK_STATUS_PLAYING_MC = 'playing';
|
||||
export const PLAYBACK_STATUS_PAUSED_MC = 'paused';
|
||||
export const PLAYBACK_STATUS_STOPPED_MC = 'stopped';
|
||||
|
||||
export type MCPlaybackStatus = 'playing' | 'stopped' | 'paused';
|
||||
|
||||
export interface MCResponseCommon {
|
||||
id: string
|
||||
name: string
|
||||
type: 'response'
|
||||
}
|
||||
|
||||
export interface MCRequestCommon {
|
||||
type: 'request'
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MCTrackResponse {
|
||||
album: string
|
||||
album_artist: string
|
||||
album_artist_id: number
|
||||
album_id: number
|
||||
artist: string
|
||||
artist_id: number
|
||||
external_id: string
|
||||
genre: string
|
||||
genre_id: number
|
||||
id: number
|
||||
thumbnail_id: number
|
||||
title: string
|
||||
track: number
|
||||
}
|
||||
|
||||
export interface MCPlaybackOverviewResponse extends MCResponseCommon {
|
||||
options: {
|
||||
muted: boolean
|
||||
play_queue_position: number
|
||||
playing_current_time: number
|
||||
playing_duration: number
|
||||
playing_track: MCTrackResponse
|
||||
repeat_mode: string
|
||||
shuffled: boolean
|
||||
state: MCPlaybackStatus
|
||||
track_count: number
|
||||
volume: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MCAuthenticateResponse extends MCResponseCommon {
|
||||
options: {
|
||||
authenticated: boolean
|
||||
environment: {
|
||||
api_version: number
|
||||
app_version: string
|
||||
http_server_enabled: boolean
|
||||
http_server_port: number
|
||||
sdk_version: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MCAuthenticateRequest extends MCRequestCommon {
|
||||
name: 'authenticate',
|
||||
device_id: string
|
||||
options: {
|
||||
password: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MCPlaybackOverviewRequest extends MCRequestCommon {
|
||||
name: 'get_playback_overview'
|
||||
device_id: string
|
||||
}
|
||||
|
||||
export interface MusikcubeData extends CommonSourceData {
|
||||
/**
|
||||
* URL of the Musikcube Websocket (Metadata) server to connect to
|
||||
*
|
||||
* You MUST have enabled 'metadata' server and set a password: https://github.com/clangen/musikcube/wiki/remote-api-documentation
|
||||
* * musikcube -> settings -> server setup
|
||||
*
|
||||
* The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.
|
||||
*
|
||||
* Parts => [default value]
|
||||
*
|
||||
* * Protocol => `ws://`
|
||||
* * Hostname => `localhost`
|
||||
* * Port => `7905`
|
||||
*
|
||||
*
|
||||
* @examples ["ws://localhost:7905"]
|
||||
* @default "ws://localhost:7905"
|
||||
* */
|
||||
url?: string
|
||||
|
||||
/**
|
||||
* Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation
|
||||
*
|
||||
* * musikcube -> settings -> server setup -> password
|
||||
* */
|
||||
password: string
|
||||
|
||||
device_id?: string
|
||||
|
||||
}
|
||||
|
||||
export interface MusikcubeSourceConfig extends CommonSourceConfig {
|
||||
data: MusikcubeData
|
||||
}
|
||||
|
||||
export interface MusikcubeSourceAIOConfig extends MusikcubeSourceConfig {
|
||||
type: 'musikcube'
|
||||
}
|
||||
@@ -25,26 +25,6 @@ export interface PlexSourceData extends CommonSourceData {
|
||||
* @examples [["MyServerName"]]
|
||||
* */
|
||||
servers?: string | string[]
|
||||
|
||||
/**
|
||||
* Additional options for Plex/Tautulli logging and tuning
|
||||
* */
|
||||
options?: {
|
||||
|
||||
/**
|
||||
* How MS should log when a Plex/Tautulli event fails a defined filter (users/servers)
|
||||
*
|
||||
* * `false` => do not log
|
||||
* * `debug` => log to DEBUG level
|
||||
* * `warn` => log to WARN level (default)
|
||||
*
|
||||
* Hint: This is useful if you are sure this source is setup correctly and you have multiple other Plex/Tautulli sources. Set to `debug` or `false` to reduce log noise.
|
||||
*
|
||||
* @default warn
|
||||
* @examples ["warn"]
|
||||
* */
|
||||
logFilterFailure?: false | 'debug' | 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
export interface PlexSourceConfig extends CommonSourceConfig {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
|
||||
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
|
||||
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
|
||||
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
|
||||
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
|
||||
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
|
||||
import { JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
|
||||
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
|
||||
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
|
||||
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
|
||||
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
|
||||
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
|
||||
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
|
||||
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
|
||||
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
|
||||
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
|
||||
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
|
||||
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
|
||||
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
|
||||
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
|
||||
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
|
||||
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
|
||||
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
|
||||
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
|
||||
import { ChromecastSourceConfig, ChromecastSourceAIOConfig } from "./chromecast.js";
|
||||
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
|
||||
|
||||
|
||||
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig;
|
||||
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig | MusikcubeSourceConfig;
|
||||
|
||||
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig;
|
||||
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig | MusikcubeSourceAIOConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface SpotifySourceData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface SubsonicData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
@@ -37,6 +37,24 @@ export interface SubsonicData extends CommonSourceData, PollingOptions {
|
||||
* @examples [30]
|
||||
* */
|
||||
maxInterval?: number
|
||||
|
||||
/**
|
||||
* If your subsonic server is using self-signed certs you may need to disable TLS errors in order to get a connection
|
||||
*
|
||||
* WARNING: This should be used with caution as your traffic may not be encrypted.
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
ignoreTlsErrors?: boolean
|
||||
|
||||
/**
|
||||
* Older Subsonic versions, and some badly implemented servers (Nextcloud), use legacy authentication which sends your password in CLEAR TEXT. This is less secure than the newer, recommended hashing authentication method but in some cases it is needed. See "Authentication" section here => https://www.subsonic.org/pages/api.jsp
|
||||
*
|
||||
* If this option is not specified it will be turned on if the subsonic server responds with error code 41 "Token authentication not supported for LDAP users." -- See Error Handling section => https://www.subsonic.org/pages/api.jsp
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
legacyAuthentication?: boolean
|
||||
}
|
||||
export interface SubSonicSourceConfig extends CommonSourceConfig {
|
||||
data: SubsonicData
|
||||
|
||||
@@ -32,31 +32,6 @@ export interface WebScrobblerData extends CommonSourceData {
|
||||
* @examples [["mixcloud","soundcloud","bandcamp"]]
|
||||
* */
|
||||
whitelist?: string | string[]
|
||||
|
||||
/**
|
||||
* Additional options for WebScrobbler logging and tuning
|
||||
* */
|
||||
options?: {
|
||||
/**
|
||||
* Log raw WebScrobbler webhook payload to debug
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
logPayload?: boolean
|
||||
|
||||
/**
|
||||
* How MS should log when a WebScrobbler event fails a defined filter
|
||||
*
|
||||
* * `false` => do not log
|
||||
* * `debug` => log to DEBUG level
|
||||
* * `warn` => log to WARN level (default)
|
||||
*
|
||||
* @default warn
|
||||
* @examples ["warn"]
|
||||
* */
|
||||
logFilterFailure?: false | 'debug' | 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebScrobblerSourceConfig extends CommonSourceConfig {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
|
||||
|
||||
export interface YTMusicData extends CommonSourceData, PollingOptions {
|
||||
export interface YTMusicCredentials {
|
||||
/**
|
||||
* The cookie retrieved from the Request Headers of music.youtube.com after logging in.
|
||||
*
|
||||
@@ -17,8 +17,17 @@ export interface YTMusicData extends CommonSourceData, PollingOptions {
|
||||
* */
|
||||
authUser?: number
|
||||
}
|
||||
|
||||
export interface YTMusicData extends YTMusicCredentials, CommonSourceData, PollingOptions {
|
||||
}
|
||||
export interface YTMusicSourceConfig extends CommonSourceConfig {
|
||||
data: YTMusicData
|
||||
options?: CommonSourceOptions & {
|
||||
/**
|
||||
* When true MS will log to DEBUG what parts of the cookie are updated by YTM
|
||||
* */
|
||||
logAuthUpdateChanges?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface YTMusicSourceAIOConfig extends YTMusicSourceConfig {
|
||||
|
||||
@@ -50,6 +50,17 @@ declare module 'lastfm-node-client' {
|
||||
}
|
||||
}
|
||||
|
||||
export type NowPlayingPayload = Omit<TrackScrobblePayload, 'mbid' | 'timestamp'>
|
||||
|
||||
export interface NowPlayingResponse {
|
||||
nowplaying: {
|
||||
ignoredMessage: {
|
||||
code: number
|
||||
'#text': string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthGetSessionPayload {
|
||||
token: string
|
||||
}
|
||||
@@ -92,7 +103,6 @@ declare module 'lastfm-node-client' {
|
||||
},
|
||||
duration: number,
|
||||
date?: {
|
||||
// @ts-ignore
|
||||
uts: number,
|
||||
},
|
||||
'@attr'?: {
|
||||
@@ -105,6 +115,7 @@ declare module 'lastfm-node-client' {
|
||||
export default class LastFM {
|
||||
constructor(apiKey: string, secret?: string, session?: string);
|
||||
|
||||
trackUpdateNowPlaying(params: NowPlayingPayload): Promise<NowPlayingResponse>
|
||||
trackScrobble(params: TrackScrobblePayload): Promise<TrackScrobbleResponse>
|
||||
authGetSession(params: AuthGetSessionPayload): Promise<AuthGetSessionResponse>
|
||||
userGetInfo(): Promise<UserGetInfoResponse>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
|
||||
declare module 'passport-deezer' {
|
||||
//import {Strategy as Oauth2Strategy} from 'passport-oauth2';
|
||||
import {Strategy as PassportStrategy} from "passport";
|
||||
import { Strategy as PassportStrategy } from "passport";
|
||||
|
||||
export class Strategy extends PassportStrategy {
|
||||
constructor(options: any, verify: any);
|
||||
}
|
||||
|
||||
+26
-363
@@ -1,380 +1,43 @@
|
||||
import { childLogger, Logger, loggerAppRolling, LogOptions, parseLogOptions, } from '@foxxmd/logging';
|
||||
import { buildDestinationJsonPrettyStream, buildDestinationStdout, buildLogger } from "@foxxmd/logging/factory";
|
||||
import { PassThrough, Transform } from "node:stream";
|
||||
import path from "path";
|
||||
import { projectDir } from "./index.js";
|
||||
import * as winstonNs from '@foxxmd/winston';
|
||||
import winstonDef from '@foxxmd/winston';
|
||||
import {DuplexTransport} from "winston-duplex";
|
||||
import { asLogOptions, LogConfig, LogOptions } from "./infrastructure/Atomic.js";
|
||||
import process from "process";
|
||||
import { fileOrDirectoryIsWriteable, mergeArr, parseBool } from "../utils.js";
|
||||
import {ErrorWithCause, stackWithCauses} from "pony-cause";
|
||||
import {NullTransport} from 'winston-null';
|
||||
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||
import dayjs from "dayjs";
|
||||
import stringify from 'safe-stable-stringify';
|
||||
import {SPLAT, LEVEL, MESSAGE} from 'triple-beam';
|
||||
import { LogInfo, LogLevel } from "../../core/Atomic.js";
|
||||
import TransportStream from "winston-transport";
|
||||
import {format} from 'logform';
|
||||
|
||||
const {combine, printf, timestamp, label, splat, errors} = format;
|
||||
|
||||
//const {transports} = winstonNew;
|
||||
const {loggers, transports} = winstonDef;
|
||||
import { projectDir } from "./index.js";
|
||||
|
||||
export let logPath = path.resolve(projectDir, `./logs`);
|
||||
if (typeof process.env.CONFIG_DIR === 'string') {
|
||||
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
|
||||
}
|
||||
|
||||
loggers.add('noop', {transports: [new NullTransport()]});
|
||||
|
||||
export const getLogger = (config: LogConfig = {}, name = 'app'): winstonNs.Logger => {
|
||||
|
||||
if (!loggers.has(name)) {
|
||||
const errors: (Error | string)[] = [];
|
||||
|
||||
let options: LogOptions = {};
|
||||
if (asLogOptions(config)) {
|
||||
options = config;
|
||||
} else {
|
||||
errors.push(`Logging levels were not valid. Must be one of: 'error', 'warn', 'info', 'verbose', 'debug' -- 'file' may be false.`);
|
||||
}
|
||||
|
||||
const {level: configLevel} = options;
|
||||
const defaultLevel = process.env.LOG_LEVEL || (parseBool(process.env.DEBUG_MODE) ? 'debug' : 'info');
|
||||
let consoleLevel: string | boolean = process.env.CONSOLE_LEVEL || 'debug';
|
||||
if(consoleLevel === 'false') {
|
||||
consoleLevel = false;
|
||||
}
|
||||
let fileLevel: string | boolean = process.env.FILE_LEVEL || defaultLevel;
|
||||
if(fileLevel === 'false') {
|
||||
fileLevel = false;
|
||||
}
|
||||
const {
|
||||
level = configLevel || defaultLevel,
|
||||
file = configLevel || fileLevel,
|
||||
stream = configLevel || 'debug',
|
||||
console = configLevel || consoleLevel
|
||||
} = options;
|
||||
|
||||
const myTransports: TransportStream[] = [
|
||||
new DuplexTransport({
|
||||
stream: {
|
||||
transform(chunk, e, cb) {
|
||||
cb(null, chunk);
|
||||
},
|
||||
objectMode: true,
|
||||
},
|
||||
name: 'duplex',
|
||||
level: stream,
|
||||
dump: false,
|
||||
})
|
||||
];
|
||||
|
||||
if(console !== false) {
|
||||
myTransports.push(new transports.Console({
|
||||
level: console,
|
||||
}));
|
||||
}
|
||||
|
||||
if (file !== false) {
|
||||
const rotateTransport = new DailyRotateFile({
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'scrobble-current.log',
|
||||
filename: 'scrobble-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m',
|
||||
level: file,
|
||||
});
|
||||
|
||||
try {
|
||||
fileOrDirectoryIsWriteable(logPath);
|
||||
// @ts-ignore
|
||||
myTransports.push(rotateTransport);
|
||||
} catch (e: any) {
|
||||
let msg = 'WILL NOT write logs to rotating file due to an error while trying to access the specified logging directory';
|
||||
errors.push(new ErrorWithCause<Error>(msg, {cause: e}));
|
||||
}
|
||||
}
|
||||
|
||||
const loggerOptions: winstonNs.LoggerOptions = {
|
||||
level: level,
|
||||
format: labelledFormat(),
|
||||
transports: myTransports,
|
||||
};
|
||||
|
||||
loggers.add(name, loggerOptions);
|
||||
|
||||
const logger = loggers.get(name);
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
return logger;
|
||||
}
|
||||
return loggers.get(name);
|
||||
export const initLogger = (): [Logger, Transform] => {
|
||||
const opts = parseLogOptions({file: false, console: 'debug'})
|
||||
const stream = new PassThrough({objectMode: true});
|
||||
const logger = buildLogger('debug', [
|
||||
buildDestinationStdout(opts.console),
|
||||
buildDestinationJsonPrettyStream(opts.console, {destination: stream, object: true, colorize: true})
|
||||
]);
|
||||
return [logger, stream];
|
||||
}
|
||||
|
||||
const breakSymbol = '<br />';
|
||||
export const formatLogToHtml = (chunk: any) => {
|
||||
const line = chunk.toString().replace('\n', breakSymbol)
|
||||
.replace(/(debug)\s/gi, '<span class="debug blue">$1 </span>')
|
||||
.replace(/(warn)\s/gi, '<span class="warn yellow">$1 </span>')
|
||||
.replace(/(info)\s/gi, '<span class="info green">$1 </span>')
|
||||
.replace(/(verbose)\s/gi, '<span class="verbose purple">$1 </span>')
|
||||
.replace(/(error)\s/gi, '<span class="error red">$1 </span>')
|
||||
.trim();
|
||||
if(line.slice(-6) !== breakSymbol) {
|
||||
return `${line}${breakSymbol}`;
|
||||
}
|
||||
return line;
|
||||
export const appLogger = async (config: LogOptions = {}): Promise<[Logger, PassThrough]> => {
|
||||
const stream = new PassThrough({objectMode: true});
|
||||
const opts = parseLogOptions(config)
|
||||
const logger = await loggerAppRolling(config, {
|
||||
logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined,
|
||||
logDefaultPath: './logs/scrobble.log',
|
||||
destinations: [
|
||||
buildDestinationJsonPrettyStream(opts.console, {destination: stream, object: true, colorize: true})
|
||||
]
|
||||
});
|
||||
return [logger, stream];
|
||||
}
|
||||
|
||||
const levelSymbol = Symbol.for('level');
|
||||
const s = splat();
|
||||
//const errorsFormat = errors({stack: true});
|
||||
const CWD = process.cwd();
|
||||
|
||||
const causeKeys = ['name', 'cause', 'showStopper']
|
||||
|
||||
export const defaultFormat = (defaultLabel = 'App') => printf(({
|
||||
label,
|
||||
[levelSymbol]: levelSym,
|
||||
level,
|
||||
message,
|
||||
labels = [defaultLabel],
|
||||
leaf,
|
||||
timestamp,
|
||||
durationMs,
|
||||
[SPLAT]: splatObj,
|
||||
stack,
|
||||
id,
|
||||
cause,
|
||||
showStopper,
|
||||
...rest
|
||||
}) => {
|
||||
const keys = Object.keys(rest);
|
||||
let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify.default(rest) : '';
|
||||
let msg = message;
|
||||
let stackMsg = '';
|
||||
if (stack !== undefined) {
|
||||
const stackArr = stack.split('\n');
|
||||
const stackTop = stackArr[0];
|
||||
const cleanedStack = stackArr
|
||||
.slice(1) // don't need actual error message since we are showing it as msg
|
||||
.map((x: string) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
|
||||
.join('\n'); // rejoin with newline to preserve formatting
|
||||
stackMsg = `\n${cleanedStack}`;
|
||||
if (msg === undefined || msg === null || typeof message === 'object') {
|
||||
msg = stackTop;
|
||||
} else {
|
||||
stackMsg = `\n${stackTop}${stackMsg}`
|
||||
}
|
||||
}
|
||||
|
||||
let nodes = Array.isArray(labels) ? labels : [labels];
|
||||
if (leaf !== null && leaf !== undefined && !nodes.includes(leaf)) {
|
||||
nodes.push(leaf);
|
||||
}
|
||||
const labelContent = `${nodes.map((x: string) => `[${x}]`).join(' ')}`;
|
||||
|
||||
return `${timestamp} ${level.padEnd(8)}: ${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
||||
});
|
||||
|
||||
// https://knowyourmeme.com/memes/cereal-guy
|
||||
// this number will never overflow
|
||||
let seqId: number = 0;
|
||||
export const labelledFormat = (labelName = 'App') => {
|
||||
const l = label({label: labelName, message: false});
|
||||
return combine(
|
||||
timestamp(
|
||||
{
|
||||
format: () => dayjs().local().format(),
|
||||
}
|
||||
),
|
||||
{
|
||||
transform: (info, opts) => {
|
||||
info.id = seqId;
|
||||
seqId++;
|
||||
return info;
|
||||
}
|
||||
},
|
||||
l,
|
||||
s,
|
||||
errorAwareFormat,
|
||||
defaultFormat(labelName),
|
||||
);
|
||||
}
|
||||
|
||||
export const logLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
verbose: 4,
|
||||
debug: 5,
|
||||
trace: 5,
|
||||
silly: 6
|
||||
};
|
||||
|
||||
export const LOG_LEVEL_REGEX: RegExp = /\s*(debug|warn|info|error|verbose)\s*:/i
|
||||
export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: LogLevel): boolean => {
|
||||
// @ts-ignore
|
||||
const minLevel = logLevels[minLevelText];
|
||||
let level: number;
|
||||
|
||||
if(typeof log === 'string') {
|
||||
const lineLevelMatch = log.match(LOG_LEVEL_REGEX)
|
||||
if (lineLevelMatch === null) {
|
||||
return false;
|
||||
}
|
||||
// @ts-ignore
|
||||
level = logLevels[lineLevelMatch[1]];
|
||||
} else {
|
||||
const lineLevelMatch = log.level;
|
||||
// @ts-ignore
|
||||
level = logLevels[lineLevelMatch];
|
||||
}
|
||||
return level <= minLevel;
|
||||
}
|
||||
|
||||
export const isLogLevelMinLevel = (levelStr: LogLevel, minLevelStr: LogLevel): boolean => {
|
||||
return logLevels[levelStr] <= logLevels[minLevelStr];
|
||||
}
|
||||
|
||||
const isProbablyError = (val: any, explicitErrorName?: string) => {
|
||||
if(typeof val !== 'object' || val === null) {
|
||||
return false;
|
||||
}
|
||||
const {name, stack} = val;
|
||||
if(explicitErrorName !== undefined) {
|
||||
if(name !== undefined && name.toLowerCase().includes(explicitErrorName)) {
|
||||
return true;
|
||||
}
|
||||
if(stack !== undefined && stack.trim().toLowerCase().indexOf(explicitErrorName.toLowerCase()) === 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} else if(stack !== undefined) {
|
||||
return true;
|
||||
} else if(name !== undefined && name.toLowerCase().includes('error')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorAwareFormat = {
|
||||
transform: (einfo: any, {stack = true}: any = {}) => {
|
||||
|
||||
// because winston logger.child() re-assigns its input to an object ALWAYS the object we recieve here will never actually be of type Error
|
||||
const includeStack = stack && (!isProbablyError(einfo, 'simpleerror') && !isProbablyError(einfo.message, 'simpleerror'));
|
||||
|
||||
if (!isProbablyError(einfo.message) && !isProbablyError(einfo)) {
|
||||
return einfo;
|
||||
}
|
||||
|
||||
let info: any = {};
|
||||
|
||||
if (isProbablyError(einfo)) {
|
||||
const tinfo = transformError(einfo);
|
||||
info = Object.assign({}, tinfo, {
|
||||
// @ts-ignore
|
||||
level: einfo.level,
|
||||
// @ts-ignore
|
||||
[LEVEL]: einfo[LEVEL] || einfo.level,
|
||||
message: tinfo.message,
|
||||
// @ts-ignore
|
||||
[MESSAGE]: tinfo[MESSAGE] || tinfo.message
|
||||
});
|
||||
if(includeStack) {
|
||||
// so we have to create a dummy error and re-assign all error properties from our info object to it so we can get a proper stack trace
|
||||
const dummyErr = new ErrorWithCause('');
|
||||
const names = Object.getOwnPropertyNames(tinfo);
|
||||
for(const k of names) {
|
||||
if(dummyErr.hasOwnProperty(k) || k === 'cause') {
|
||||
// @ts-ignore
|
||||
dummyErr[k] = tinfo[k];
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
info.stack = stackWithCauses(dummyErr);
|
||||
}
|
||||
} else {
|
||||
const err = transformError(einfo.message);
|
||||
info = Object.assign({}, einfo, err);
|
||||
// @ts-ignore
|
||||
info.message = err.message;
|
||||
// @ts-ignore
|
||||
info[MESSAGE] = err.message;
|
||||
|
||||
if(includeStack) {
|
||||
const dummyErr = new ErrorWithCause('');
|
||||
// Error properties are not enumerable
|
||||
// https://stackoverflow.com/a/18278145/1469797
|
||||
const names = Object.getOwnPropertyNames(err);
|
||||
for(const k of names) {
|
||||
if(dummyErr.hasOwnProperty(k) || k === 'cause') {
|
||||
// @ts-ignore
|
||||
dummyErr[k] = err[k];
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
info.stack = stackWithCauses(dummyErr);
|
||||
}
|
||||
}
|
||||
|
||||
// remove redundant message from stack and make stack causes easier to read
|
||||
if(info.stack !== undefined) {
|
||||
let cleanedStack = info.stack.replace(info.message, '');
|
||||
cleanedStack = `${cleanedStack}`;
|
||||
cleanedStack = cleanedStack.replaceAll('caused by:', '\ncaused by:');
|
||||
info.stack = cleanedStack;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
export const transformError = (err: Error): any => _transformError(err, new Set());
|
||||
|
||||
const _transformError = (err: Error, seen: Set<Error>) => {
|
||||
if (!err || !isProbablyError(err)) {
|
||||
return '';
|
||||
}
|
||||
if (seen.has(err)) {
|
||||
return err;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// @ts-ignore
|
||||
let mOpts = err.matchOptions ?? matchOptions;
|
||||
|
||||
// @ts-ignore
|
||||
const cause = err.cause as unknown;
|
||||
|
||||
if (cause !== undefined && cause instanceof Error) {
|
||||
// @ts-ignore
|
||||
err.cause = _transformError(cause, seen, mOpts);
|
||||
}
|
||||
|
||||
return err;
|
||||
} catch (e: any) {
|
||||
// oops :(
|
||||
// we're gonna swallow silently instead of reporting to avoid any infinite nesting and hopefully the original error looks funny enough to provide clues as to what to fix here
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
export class MaybeLogger {
|
||||
logger?: winstonNs.Logger
|
||||
logger?: Logger
|
||||
|
||||
constructor(logger?: winstonNs.Logger, label?: string) {
|
||||
constructor(logger?: Logger, label?: string) {
|
||||
if (logger !== undefined && label !== undefined) {
|
||||
this.logger = logger.child({labels: [label]}, mergeArr);
|
||||
this.logger = childLogger(logger, label);
|
||||
} else {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@@ -16,71 +16,74 @@
|
||||
"title": "ClientAIOConfig"
|
||||
},
|
||||
"CommonClientData": {
|
||||
"title": "CommonClientData",
|
||||
"type": "object"
|
||||
},
|
||||
"CommonClientOptions": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a client can make before error is thrown.",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshInitialCount": {
|
||||
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
|
||||
"title": "refreshInitialCount",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "CommonClientData",
|
||||
"title": "CommonClientOptions",
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmClientAIOConfig": {
|
||||
@@ -127,6 +130,10 @@
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"lastfm"
|
||||
@@ -155,7 +162,7 @@
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
@@ -238,6 +245,10 @@
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"listenbrainz"
|
||||
@@ -258,57 +269,13 @@
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
@@ -372,6 +339,10 @@
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"maloja"
|
||||
@@ -400,57 +371,13 @@
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
@@ -514,7 +441,7 @@
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
|
||||
@@ -77,42 +77,6 @@
|
||||
"title": "forceMediaRecognitionOn",
|
||||
"type": "array"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"useAutoDiscovery": {
|
||||
"description": "Use mDNS to discovery Google Cast devices on your next automatically?\n\nIf not explicitly set then it is TRUE if `devices` is not set",
|
||||
"title": "useAutoDiscovery",
|
||||
@@ -282,6 +246,33 @@
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
@@ -291,6 +282,16 @@
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklogCount": {
|
||||
"description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports",
|
||||
"title": "scrobbleBacklogCount",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"title": "CommonSourceOptions",
|
||||
@@ -323,28 +324,6 @@
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/deezer/callback",
|
||||
"description": "deezer redirect URI -- required only if not the default shown here. URI must end in \"callback\"",
|
||||
@@ -353,20 +332,6 @@
|
||||
],
|
||||
"title": "redirectUri",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -450,47 +415,11 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"password": {
|
||||
"description": "If you have enabled authentication, the password you set",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"url": {
|
||||
"default": "http://localhost:52199/MCWS/v1/",
|
||||
"description": "URL of the JRiver HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:52199/MCWS/v1/`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `52199`\n* Path => `/MCWS/v1/`",
|
||||
@@ -567,67 +496,6 @@
|
||||
},
|
||||
"JellyData": {
|
||||
"properties": {
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for jellyfin logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a Jellyfin event fails a defined filter (users/servers)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other Jellyfin sources. Set to `debug` or `false` to reduce log noise.",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw Jellyfin webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"servers": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -746,47 +614,11 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"password": {
|
||||
"description": "The password set for Remote Control via Web Sever",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"url": {
|
||||
"default": "http://localhost:8080/jsonrpc",
|
||||
"description": "URL of the Kodi HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:8080/jsonrpc`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `8080`\n* Path => `/jsonrpc`",
|
||||
@@ -956,28 +788,15 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
@@ -996,11 +815,6 @@
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
@@ -1107,28 +921,15 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
@@ -1138,11 +939,6 @@
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"token": {
|
||||
"description": "User token for the user to scrobble for",
|
||||
"examples": [
|
||||
@@ -1196,42 +992,6 @@
|
||||
],
|
||||
"title": "blacklist"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"whitelist": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1347,42 +1107,6 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"uriBlacklist": {
|
||||
"description": "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.\n\nList is ignored if uriWhitelist is used.",
|
||||
"items": {
|
||||
@@ -1465,6 +1189,86 @@
|
||||
"title": "MopidySourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"MusikcubeData": {
|
||||
"properties": {
|
||||
"device_id": {
|
||||
"title": "device_id",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation\n\n* musikcube -> settings -> server setup -> password",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"default": "ws://localhost:7905",
|
||||
"description": "URL of the Musikcube Websocket (Metadata) server to connect to\n\nYou MUST have enabled 'metadata' server and set a password: https://github.com/clangen/musikcube/wiki/remote-api-documentation\n * musikcube -> settings -> server setup\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `ws://`\n* Hostname => `localhost`\n* Port => `7905`",
|
||||
"examples": [
|
||||
"ws://localhost:7905"
|
||||
],
|
||||
"title": "url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"title": "MusikcubeData",
|
||||
"type": "object"
|
||||
},
|
||||
"MusikcubeSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/MusikcubeData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"musikcube"
|
||||
],
|
||||
"title": "type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"title": "MusikcubeSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"PlexSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
@@ -1541,58 +1345,6 @@
|
||||
],
|
||||
"title": "libraries"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for Plex/Tautulli logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a Plex/Tautulli event fails a defined filter (users/servers)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other Plex/Tautulli sources. Set to `debug` or `false` to reduce log noise.",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"servers": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1638,10 +1390,6 @@
|
||||
"title": "PlexSourceData",
|
||||
"type": "object"
|
||||
},
|
||||
"Record<string,any>": {
|
||||
"title": "Record<string,any>",
|
||||
"type": "object"
|
||||
},
|
||||
"ScrobbleThresholds": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
@@ -1669,49 +1417,52 @@
|
||||
"SourceAIOConfig": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SpotifySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/PlexSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/TautulliSourceAIOConfig"
|
||||
"$ref": "#/definitions/ChromecastSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/DeezerSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubsonicSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JellySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastFmSouceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/YTMusicSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPRISSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MopidySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ListenBrainzSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JRiverSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/KodiSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastFmSouceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ListenBrainzSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MopidySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPRISSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MusikcubeSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/PlexSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SpotifySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubsonicSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/TautulliSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/WebScrobblerSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ChromecastSourceAIOConfig"
|
||||
"$ref": "#/definitions/YTMusicSourceAIOConfig"
|
||||
}
|
||||
],
|
||||
"title": "SourceAIOConfig"
|
||||
@@ -1729,7 +1480,7 @@
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
@@ -1838,28 +1589,6 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/callback",
|
||||
"description": "spotify redirect URI -- required only if not the default shown here. URI must end in \"callback\"",
|
||||
@@ -1868,20 +1597,6 @@
|
||||
],
|
||||
"title": "redirectUri",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1894,6 +1609,12 @@
|
||||
},
|
||||
"SubsonicData": {
|
||||
"properties": {
|
||||
"ignoreTlsErrors": {
|
||||
"default": false,
|
||||
"description": "If your subsonic server is using self-signed certs you may need to disable TLS errors in order to get a connection\n\nWARNING: This should be used with caution as your traffic may not be encrypted.",
|
||||
"title": "ignoreTlsErrors",
|
||||
"type": "boolean"
|
||||
},
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
@@ -1903,6 +1624,12 @@
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"legacyAuthentication": {
|
||||
"default": false,
|
||||
"description": "Older Subsonic versions, and some badly implemented servers (Nextcloud), use legacy authentication which sends your password in CLEAR TEXT. This is less secure than the newer, recommended hashing authentication method but in some cases it is needed. See \"Authentication\" section here => https://www.subsonic.org/pages/api.jsp\n\nIf this option is not specified it will be turned on if the subsonic server responds with error code 41 \"Token authentication not supported for LDAP users.\" -- See Error Handling section => https://www.subsonic.org/pages/api.jsp",
|
||||
"title": "legacyAuthentication",
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
@@ -1912,28 +1639,6 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"password": {
|
||||
"description": "Password for the user to login to the server with",
|
||||
"examples": [
|
||||
@@ -1942,20 +1647,6 @@
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL of the subsonic media server to query",
|
||||
"examples": [
|
||||
@@ -2106,67 +1797,6 @@
|
||||
],
|
||||
"title": "blacklist"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for WebScrobbler logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a WebScrobbler event fails a defined filter\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw WebScrobbler webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"slug": {
|
||||
"description": "The URL ending that should be used to identify scrobbles for this source\n\nIn WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:\n\nhttp://localhost:9078/api/webscrobbler\n\nIf you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.\n\nExample:\n\n* slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera\n* slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb\n\nIf no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used",
|
||||
"title": "slug",
|
||||
@@ -2282,42 +1912,6 @@
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2361,7 +1955,21 @@
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommonSourceOptions"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"logAuthUpdateChanges": {
|
||||
"description": "When true MS will log to DEBUG what parts of the cookie are updated by YTM",
|
||||
"title": "logAuthUpdateChanges",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
|
||||
+536
-755
File diff suppressed because it is too large
Load Diff
@@ -13,71 +13,74 @@
|
||||
],
|
||||
"definitions": {
|
||||
"CommonClientData": {
|
||||
"title": "CommonClientData",
|
||||
"type": "object"
|
||||
},
|
||||
"CommonClientOptions": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a client can make before error is thrown.",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshInitialCount": {
|
||||
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
|
||||
"title": "refreshInitialCount",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "CommonClientData",
|
||||
"title": "CommonClientOptions",
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmClientConfig": {
|
||||
@@ -123,6 +126,10 @@
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -144,7 +151,7 @@
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
@@ -226,6 +233,10 @@
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -239,57 +250,13 @@
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
@@ -352,6 +319,10 @@
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -373,57 +344,13 @@
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
|
||||
@@ -2,49 +2,52 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SpotifySourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/PlexSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/TautulliSourceConfig"
|
||||
"$ref": "#/definitions/ChromecastSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/DeezerSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubSonicSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JellySourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastfmSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/YTMusicSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPRISSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MopidySourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ListenBrainzSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JRiverSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/KodiSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastfmSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ListenBrainzSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MopidySourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPRISSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MusikcubeSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/PlexSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SpotifySourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubSonicSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/TautulliSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/WebScrobblerSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ChromecastSourceConfig"
|
||||
"$ref": "#/definitions/YTMusicSourceConfig"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
@@ -124,42 +127,6 @@
|
||||
"title": "forceMediaRecognitionOn",
|
||||
"type": "array"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"useAutoDiscovery": {
|
||||
"description": "Use mDNS to discovery Google Cast devices on your next automatically?\n\nIf not explicitly set then it is TRUE if `devices` is not set",
|
||||
"title": "useAutoDiscovery",
|
||||
@@ -321,6 +288,33 @@
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
@@ -330,6 +324,16 @@
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklogCount": {
|
||||
"description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports",
|
||||
"title": "scrobbleBacklogCount",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"title": "CommonSourceOptions",
|
||||
@@ -362,28 +366,6 @@
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/deezer/callback",
|
||||
"description": "deezer redirect URI -- required only if not the default shown here. URI must end in \"callback\"",
|
||||
@@ -392,20 +374,6 @@
|
||||
],
|
||||
"title": "redirectUri",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -481,47 +449,11 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"password": {
|
||||
"description": "If you have enabled authentication, the password you set",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"url": {
|
||||
"default": "http://localhost:52199/MCWS/v1/",
|
||||
"description": "URL of the JRiver HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:52199/MCWS/v1/`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `52199`\n* Path => `/MCWS/v1/`",
|
||||
@@ -590,67 +522,6 @@
|
||||
},
|
||||
"JellyData": {
|
||||
"properties": {
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for jellyfin logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a Jellyfin event fails a defined filter (users/servers)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other Jellyfin sources. Set to `debug` or `false` to reduce log noise.",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw Jellyfin webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"servers": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -761,47 +632,11 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"password": {
|
||||
"description": "The password set for Remote Control via Web Sever",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"url": {
|
||||
"default": "http://localhost:8080/jsonrpc",
|
||||
"description": "URL of the Kodi HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:8080/jsonrpc`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `8080`\n* Path => `/jsonrpc`",
|
||||
@@ -898,28 +733,15 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
@@ -938,11 +760,6 @@
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
@@ -1098,28 +915,15 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
@@ -1129,11 +933,6 @@
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"token": {
|
||||
"description": "User token for the user to scrobble for",
|
||||
"examples": [
|
||||
@@ -1187,42 +986,6 @@
|
||||
],
|
||||
"title": "blacklist"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"whitelist": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1330,42 +1093,6 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"uriBlacklist": {
|
||||
"description": "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.\n\nList is ignored if uriWhitelist is used.",
|
||||
"items": {
|
||||
@@ -1440,6 +1167,78 @@
|
||||
"title": "MopidySourceConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"MusikcubeData": {
|
||||
"properties": {
|
||||
"device_id": {
|
||||
"title": "device_id",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation\n\n* musikcube -> settings -> server setup -> password",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"default": "ws://localhost:7905",
|
||||
"description": "URL of the Musikcube Websocket (Metadata) server to connect to\n\nYou MUST have enabled 'metadata' server and set a password: https://github.com/clangen/musikcube/wiki/remote-api-documentation\n * musikcube -> settings -> server setup\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `ws://`\n* Hostname => `localhost`\n* Port => `7905`",
|
||||
"examples": [
|
||||
"ws://localhost:7905"
|
||||
],
|
||||
"title": "url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"title": "MusikcubeData",
|
||||
"type": "object"
|
||||
},
|
||||
"MusikcubeSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/MusikcubeData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"title": "MusikcubeSourceConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"PlexSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
@@ -1508,58 +1307,6 @@
|
||||
],
|
||||
"title": "libraries"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for Plex/Tautulli logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a Plex/Tautulli event fails a defined filter (users/servers)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other Plex/Tautulli sources. Set to `debug` or `false` to reduce log noise.",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"servers": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1605,10 +1352,6 @@
|
||||
"title": "PlexSourceData",
|
||||
"type": "object"
|
||||
},
|
||||
"Record<string,any>": {
|
||||
"title": "Record<string,any>",
|
||||
"type": "object"
|
||||
},
|
||||
"ScrobbleThresholds": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
@@ -1714,28 +1457,6 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/callback",
|
||||
"description": "spotify redirect URI -- required only if not the default shown here. URI must end in \"callback\"",
|
||||
@@ -1744,20 +1465,6 @@
|
||||
],
|
||||
"title": "redirectUri",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1815,6 +1522,12 @@
|
||||
},
|
||||
"SubsonicData": {
|
||||
"properties": {
|
||||
"ignoreTlsErrors": {
|
||||
"default": false,
|
||||
"description": "If your subsonic server is using self-signed certs you may need to disable TLS errors in order to get a connection\n\nWARNING: This should be used with caution as your traffic may not be encrypted.",
|
||||
"title": "ignoreTlsErrors",
|
||||
"type": "boolean"
|
||||
},
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
@@ -1824,6 +1537,12 @@
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"legacyAuthentication": {
|
||||
"default": false,
|
||||
"description": "Older Subsonic versions, and some badly implemented servers (Nextcloud), use legacy authentication which sends your password in CLEAR TEXT. This is less secure than the newer, recommended hashing authentication method but in some cases it is needed. See \"Authentication\" section here => https://www.subsonic.org/pages/api.jsp\n\nIf this option is not specified it will be turned on if the subsonic server responds with error code 41 \"Token authentication not supported for LDAP users.\" -- See Error Handling section => https://www.subsonic.org/pages/api.jsp",
|
||||
"title": "legacyAuthentication",
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
@@ -1833,28 +1552,6 @@
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"password": {
|
||||
"description": "Password for the user to login to the server with",
|
||||
"examples": [
|
||||
@@ -1863,20 +1560,6 @@
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL of the subsonic media server to query",
|
||||
"examples": [
|
||||
@@ -1966,67 +1649,6 @@
|
||||
],
|
||||
"title": "blacklist"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for WebScrobbler logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a WebScrobbler event fails a defined filter\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw WebScrobbler webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"slug": {
|
||||
"description": "The URL ending that should be used to identify scrobbles for this source\n\nIn WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:\n\nhttp://localhost:9078/api/webscrobbler\n\nIf you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.\n\nExample:\n\n* slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera\n* slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb\n\nIf no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used",
|
||||
"title": "slug",
|
||||
@@ -2132,42 +1754,6 @@
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2211,7 +1797,21 @@
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommonSourceOptions"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"logAuthUpdateChanges": {
|
||||
"description": "When true MS will log to DEBUG what parts of the cookie are updated by YTM",
|
||||
"title": "logAuthUpdateChanges",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
|
||||
+10
-8
@@ -1,9 +1,7 @@
|
||||
import { mergeArr } from "../../utils.js";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import winston from '@foxxmd/winston';
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { capitalize } from "../../../core/StringUtils.js";
|
||||
import { AbstractApiOptions, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
|
||||
export default abstract class AbstractApiClient {
|
||||
name: string;
|
||||
@@ -18,16 +16,20 @@ export default abstract class AbstractApiClient {
|
||||
workingCredsPath?: string;
|
||||
redirectUri?: string;
|
||||
|
||||
constructor(type: any, name: any, config = {}, options = {}) {
|
||||
constructor(type: any, name: any, config = {}, options: AbstractApiOptions) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `API - ${capitalize(this.type)} - ${name}`;
|
||||
this.logger = winston.loggers.get('app').child({labels: [identifier]}, mergeArr);
|
||||
const {
|
||||
logger: parentLogger,
|
||||
...restOptions
|
||||
} = options;
|
||||
this.logger = childLogger(parentLogger, identifier);
|
||||
this.config = config;
|
||||
this.options = options;
|
||||
this.options = restOptions;
|
||||
}
|
||||
|
||||
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => {
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions): PlayObject {
|
||||
throw new Error('should be overridden');
|
||||
}
|
||||
}
|
||||
|
||||
+8
-9
@@ -1,9 +1,8 @@
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import {JRiverData} from "../infrastructure/config/source/jriver.js";
|
||||
import request, {Request, Response} from 'superagent';
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import xml2js from 'xml2js';
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {DEFAULT_RETRY_MULTIPLIER} from "../infrastructure/Atomic.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../infrastructure/Atomic.js";
|
||||
import { JRiverData } from "../infrastructure/config/source/jriver.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
|
||||
const parser = new xml2js.Parser({'async': true});
|
||||
|
||||
@@ -97,7 +96,7 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
|
||||
token?: string;
|
||||
|
||||
constructor(name: any, config: JRiverData, options = {}) {
|
||||
constructor(name: any, config: JRiverData, options: AbstractApiOptions) {
|
||||
super('JRiver', name, config, options);
|
||||
const {
|
||||
url = 'http://localhost:52199/MCWS/v1/'
|
||||
@@ -134,13 +133,13 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
this.logger.verbose(`Found ${data.ProgramName} ${data.ProgramVersion} (${data.FriendlyName})`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Could not communicate with JRiver server. Verify your server URL is correct.', {cause: e});
|
||||
throw new Error('Could not communicate with JRiver server. Verify your server URL is correct.', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
let req = request.get(`${this.url}Authenticate`);
|
||||
const req = request.get(`${this.url}Authenticate`);
|
||||
if (this.config.username !== undefined) {
|
||||
req.auth(this.config.username, this.config.password);
|
||||
}
|
||||
@@ -152,7 +151,7 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
if(this.config.username === undefined || this.config.password === undefined) {
|
||||
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
|
||||
}
|
||||
this.logger.error(new ErrorWithCause(msg, {cause: e}));
|
||||
this.logger.error(new Error(msg, {cause: e}));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-13
@@ -1,13 +1,12 @@
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { KodiData } from "../infrastructure/config/source/kodi.js";
|
||||
import dayjs from "dayjs";
|
||||
import { KodiClient } from 'kodi-api'
|
||||
import normalizeUrl from "normalize-url";
|
||||
import {URL} from "url";
|
||||
import { RecentlyPlayedOptions } from "../../sources/AbstractSource.js";
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import dayjs from "dayjs";
|
||||
import { URL } from "url";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { RecentlyPlayedOptions } from "../../sources/AbstractSource.js";
|
||||
import { AbstractApiOptions, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { KodiData } from "../infrastructure/config/source/kodi.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
|
||||
interface KodiDuration {
|
||||
hours: number
|
||||
@@ -49,7 +48,7 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
|
||||
declare client: KodiClient;
|
||||
|
||||
constructor(name: any, config: KodiData, options = {}) {
|
||||
constructor(name: any, config: KodiData, options: AbstractApiOptions) {
|
||||
super('Kodi', name, config, options);
|
||||
const {
|
||||
url = 'http://localhost:8080/jsonrpc'
|
||||
@@ -99,8 +98,8 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
playerid,
|
||||
} = obj;
|
||||
|
||||
let artists = artistVal === null || artistVal === undefined ? [] : artistVal;
|
||||
let album = albumVal === null || albumVal === '' ? undefined : albumVal;
|
||||
const artists = artistVal === null || artistVal === undefined ? [] : artistVal;
|
||||
const album = albumVal === null || albumVal === '' ? undefined : albumVal;
|
||||
const trackProgressPosition = time !== undefined ? Math.round(dayjs.duration(time).asSeconds()) : undefined;
|
||||
|
||||
return {
|
||||
@@ -143,21 +142,21 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
if(this.config.username === undefined || this.config.password === undefined) {
|
||||
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
|
||||
}
|
||||
this.logger.error(new ErrorWithCause(msg, {cause: e}));
|
||||
this.logger.error(new Error(msg, {cause: e}));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getPlayerInfo = async (id: number): Promise<PlayerInfo> => {
|
||||
// https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetProperties
|
||||
// @ts-ignore
|
||||
// @ts-expect-error types are wrong
|
||||
const playerInfo = await this.client.Player.GetProperties(0, ["position","type","time","totaltime"])
|
||||
return playerInfo;
|
||||
}
|
||||
|
||||
getPlayerItem = async (id: number): Promise<{item: PlayerItem}> => {
|
||||
// https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetItem
|
||||
// @ts-ignore
|
||||
// @ts-expect-error types are wrong
|
||||
const itemInfo = await this.client.Player.GetItem(0, ["title","artist","album","albumartist","starttime","endtime","duration","streamdetails","uniqueid"]);
|
||||
return itemInfo as {item: PlayerItem};
|
||||
}
|
||||
|
||||
+111
-18
@@ -1,14 +1,20 @@
|
||||
import LastFm, {AuthGetSessionResponse, TrackObject, UserGetInfoResponse} from "lastfm-node-client";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import dayjs from "dayjs";
|
||||
import { readJson, sleep, writeFile } from "../../utils.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { LastfmData } from "../infrastructure/config/client/lastfm.js";
|
||||
import LastFm, {
|
||||
AuthGetSessionResponse,
|
||||
NowPlayingResponse,
|
||||
TrackObject,
|
||||
TrackScrobblePayload,
|
||||
UserGetInfoResponse
|
||||
} from "lastfm-node-client";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { isNodeNetworkException } from "../errors/NodeErrors.js";
|
||||
import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js";
|
||||
import {source} from "common-tags";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { joinedUrl, readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js";
|
||||
import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js";
|
||||
import { getNodeNetworkException, isNodeNetworkException } from "../errors/NodeErrors.js";
|
||||
import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { LastfmData } from "../infrastructure/config/client/lastfm.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
@@ -29,10 +35,10 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
user?: string;
|
||||
declare config: LastfmData;
|
||||
|
||||
constructor(name: any, config: Partial<LastfmData> & {configDir: string, localUrl: string}, options = {}) {
|
||||
constructor(name: any, config: Partial<LastfmData> & {configDir: string, localUrl: URL}, options: AbstractApiOptions) {
|
||||
super('lastfm', name, config, options);
|
||||
const {redirectUri, apiKey, secret, session, configDir} = config;
|
||||
this.redirectUri = `${redirectUri ?? `${config.localUrl}/lastfm/callback`}?state=${name}`;
|
||||
this.redirectUri = `${redirectUri ?? joinedUrl(config.localUrl, 'lastfm/callback').href}?state=${name}`;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config!");
|
||||
}
|
||||
@@ -40,7 +46,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
this.client = new LastFm(apiKey as string, secret, session);
|
||||
}
|
||||
|
||||
static formatPlayObj = (obj: TrackObject, options: FormatPlayObjectOptions = {}): PlayObject => {
|
||||
static formatPlayObj(obj: TrackObject, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
const {
|
||||
artist: {
|
||||
'#text': artists,
|
||||
@@ -54,7 +60,6 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
},
|
||||
duration,
|
||||
date: {
|
||||
// @ts-ignore
|
||||
uts: time,
|
||||
} = {},
|
||||
'@attr': {
|
||||
@@ -64,7 +69,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
mbid,
|
||||
} = obj;
|
||||
// arbitrary decision yikes
|
||||
let artistStrings = splitByFirstFound(artists, [','], [artistName]);
|
||||
const artistStrings = splitByFirstFound(artists, [','], [artistName]);
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
@@ -105,15 +110,27 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
} = e;
|
||||
// for now check for exceptional errors by matching error code text
|
||||
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
|
||||
if (undefined !== retryError) {
|
||||
let networkError = null;
|
||||
if(retryError === undefined) {
|
||||
const nError = getNodeNetworkException(e);
|
||||
if(nError !== undefined) {
|
||||
networkError = nError.message;
|
||||
} else if(message.includes('ETIMEDOUT')) {
|
||||
networkError = 'request timed out after 3 seconds'
|
||||
}
|
||||
}
|
||||
if (undefined !== retryError || networkError !== undefined) {
|
||||
if (retries < maxRequestRetries) {
|
||||
const delay = (retries + 1) * retryMultiplier;
|
||||
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
|
||||
if(networkError !== undefined) {
|
||||
this.logger.warn(`API call failed due to network issue (${networkError}), retrying in ${delay} seconds...`);
|
||||
} else {
|
||||
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
|
||||
}
|
||||
await sleep(delay * 1000);
|
||||
return this.callApi(func, retries + 1);
|
||||
} else {
|
||||
this.logger.warn('Could not recover!');
|
||||
throw e;
|
||||
throw new UpstreamError(`API call failed due -> ${retryError ?? 'API call timed out'} <- after max retries hit ${maxRequestRetries}`, {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +167,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Current lastfm credentials file exists but could not be parsed', {cause: e});
|
||||
throw new Error('Current lastfm credentials file exists but could not be parsed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +197,80 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
public playToClientPayload(playObj: PlayObject): TrackScrobblePayload {
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
album,
|
||||
albumArtists = [],
|
||||
track,
|
||||
duration,
|
||||
playDate,
|
||||
meta: {
|
||||
brainz: {
|
||||
track: mbid
|
||||
} = {},
|
||||
} = {}
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
let artist: string;
|
||||
if (artists.length === 0) {
|
||||
artist = "";
|
||||
} else {
|
||||
artist = artists[0];
|
||||
}
|
||||
|
||||
const rawPayload: TrackScrobblePayload = {
|
||||
artist: artist,
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: getScrobbleTsSOCDate(playObj).unix(),
|
||||
mbid,
|
||||
};
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
if (albumArtists.length > 0) {
|
||||
rawPayload.albumArtist = albumArtists[0];
|
||||
}
|
||||
|
||||
// I don't know if its lastfm-node-client building the request params incorrectly
|
||||
// or the last.fm api not handling the params correctly...
|
||||
//
|
||||
// ...but in either case if any of the below properties is undefined (possibly also null??)
|
||||
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
|
||||
// so remove all undefined keys from the object before passing to the api client
|
||||
return removeUndefinedKeys(rawPayload);
|
||||
}
|
||||
|
||||
updateNowPlaying = async (play: PlayObject) => {
|
||||
try {
|
||||
const {timestamp, mbid, ...rest} = this.playToClientPayload(play);
|
||||
const response = await this.callApi<NowPlayingResponse>((client: LastFm) => {
|
||||
return client.trackUpdateNowPlaying(rest)
|
||||
});
|
||||
const {
|
||||
nowplaying: {
|
||||
ignoredMessage: {
|
||||
code: ignoreCode,
|
||||
'#text': ignoreMsg,
|
||||
} = {},
|
||||
} = {}
|
||||
} = response;
|
||||
if (ignoreCode > 0) {
|
||||
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) {
|
||||
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: true});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-16
@@ -1,12 +1,9 @@
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import request, {Request} from 'superagent';
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import dayjs from "dayjs";
|
||||
import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import { combinePartsToString } from "../../utils.js";
|
||||
import dayjs from "dayjs";
|
||||
import request, { Request } from 'superagent';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { slice } from "../../../core/StringUtils.js";
|
||||
import { combinePartsToString } from "../../utils.js";
|
||||
import {
|
||||
findDelimiters,
|
||||
normalizeStr,
|
||||
@@ -15,8 +12,11 @@ import {
|
||||
parseTrackCredits,
|
||||
uniqueNormalizedStrArr,
|
||||
} from "../../utils/StringUtils.js";
|
||||
import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js";
|
||||
import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
@@ -118,7 +118,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
declare config: ListenBrainzClientData;
|
||||
url: string;
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientData, options = {}) {
|
||||
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
|
||||
super('ListenBrainz', name, config, options);
|
||||
const {
|
||||
url = 'https://api.listenbrainz.org/'
|
||||
@@ -150,7 +150,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
if(status !== undefined) {
|
||||
const msgParts = [`(HTTP Status ${status})`];
|
||||
// if the response is 400 then its likely there was an issue with the data we sent rather than an error with the service
|
||||
let showStopper = status !== 400;
|
||||
const showStopper = status !== 400;
|
||||
if(body !== undefined) {
|
||||
if(typeof body === 'object') {
|
||||
if('code' in body) {
|
||||
@@ -182,10 +182,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
const resp = await this.callApi(request.get(this.url))
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(e.status === 410) {
|
||||
if(e.status === 410 || e.message.includes('HTTP Status 410')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,6 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await this.getUserListens(maxTracks, user);
|
||||
const now = await this.getPlayingNow(user);
|
||||
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
|
||||
} catch (e) {
|
||||
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
|
||||
@@ -292,7 +291,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
static listenResponseToPlay = (listen: ListenResponse): PlayObject => {
|
||||
static listenResponseToPlay(listen: ListenResponse): PlayObject {
|
||||
const {
|
||||
listened_at,
|
||||
track_metadata: {
|
||||
@@ -393,7 +392,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
// now try to extract any remaining artists from filtered artist/name values
|
||||
let parsedArtists = parseArtistCredits(filteredSubmittedArtistName);
|
||||
const parsedArtists = parseArtistCredits(filteredSubmittedArtistName);
|
||||
if (parsedArtists !== undefined) {
|
||||
if (parsedArtists.primary !== undefined) {
|
||||
artistsFromUserValues.push(parsedArtists.primary);
|
||||
@@ -513,7 +512,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
/**
|
||||
* Try to parse true artists and track name without using MB information
|
||||
* */
|
||||
static listenResponseToNaivePlay = (listen: ListenResponse): PlayObject => {
|
||||
static listenResponseToNaivePlay(listen: ListenResponse): PlayObject {
|
||||
const {
|
||||
listened_at,
|
||||
recording_msid,
|
||||
@@ -582,7 +581,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => {
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions): PlayObject {
|
||||
return ListenbrainzApiClient.listenResponseToPlay(obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { MdnsDeviceInfo, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../infrastructure/Atomic.js";
|
||||
import { PlatformApplication, PlatformType } from "./interfaces.js";
|
||||
import {connect, createPlatform, Media, MediaController, PersistentClient, Result} from "@foxxmd/chromecast-client";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {Client as CastClient} from 'castv2';
|
||||
import { Media, MediaController, Result } from "@foxxmd/chromecast-client";
|
||||
import objectHash from "object-hash";
|
||||
import { PlayObject } from "../../../../core/Atomic.js";
|
||||
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../infrastructure/Atomic.js";
|
||||
import { PlatformApplication, PlatformType } from "./interfaces.js";
|
||||
|
||||
export const chromePlayerStateToReported = (state: string): ReportedPlayerStatus => {
|
||||
switch (state) {
|
||||
@@ -28,7 +26,7 @@ export const getCurrentPlatformApplications = async (platform: PlatformType): Pr
|
||||
try {
|
||||
statusRes = await platform.getStatus()
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Unable to fetch platform statuses', {cause: e});
|
||||
throw new Error('Unable to fetch platform statuses', {cause: e});
|
||||
}
|
||||
|
||||
let status: {applications?: PlatformApplication[]};
|
||||
@@ -40,7 +38,7 @@ export const getCurrentPlatformApplications = async (platform: PlatformType): Pr
|
||||
}
|
||||
return status.applications;
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Unable to fetch platform statuses', {cause: e});
|
||||
throw new Error('Unable to fetch platform statuses', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +50,7 @@ export const getMediaStatus = async (controller: MediaController.MediaController
|
||||
status = statusRes.unwrapAndThrow();
|
||||
return status;
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Unable to fetch media status', {cause: e});
|
||||
throw new Error('Unable to fetch media status', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import {createPlatform, MediaController, PersistentClient} from "@foxxmd/chromecast-client";
|
||||
import { createPlatform, MediaController } from "@foxxmd/chromecast-client";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { FormatPlayObjectOptions } from "../../infrastructure/Atomic.js";
|
||||
import {Dayjs} from "dayjs";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
|
||||
export type PlatformType = ReturnType<typeof createPlatform>;
|
||||
export interface PlatformApplication {
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import {ResponseError} from "superagent";
|
||||
import { findCauseByFunc } from "../../../utils.js";
|
||||
import { ResponseError } from "superagent";
|
||||
|
||||
import { findCauseByFunc } from "../../../utils/ErrorUtils.js";
|
||||
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
|
||||
|
||||
export interface MalojaV2ScrobbleData {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import {Response} from 'superagent';
|
||||
import { Response } from 'superagent';
|
||||
|
||||
export interface SubsonicResponseCommon {
|
||||
status: 'failed' | 'ok'
|
||||
|
||||
+26
-26
@@ -1,25 +1,21 @@
|
||||
import 'dotenv/config';
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { childLogger, LogDataPretty, Logger as FoxLogger } from "@foxxmd/logging";
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import duration from 'dayjs/plugin/duration.js';
|
||||
import isBetween from 'dayjs/plugin/isBetween.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
import duration from 'dayjs/plugin/duration.js';
|
||||
import timezone from 'dayjs/plugin/timezone.js';
|
||||
import { parseBool, readJson, sleep } from "./utils.js";
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import * as path from "path";
|
||||
import { SimpleIntervalJob, ToadScheduler } from "toad-scheduler";
|
||||
import { projectDir } from "./common/index.js";
|
||||
import SpotifySource from "./sources/SpotifySource.js";
|
||||
import { AIOConfig } from "./common/infrastructure/config/aioConfig.js";
|
||||
import { getRoot } from "./ioc.js";
|
||||
import { getLogger } from "./common/logging.js";
|
||||
import { LogInfo } from "../core/Atomic.js";
|
||||
import { appLogger, initLogger as getInitLogger } from "./common/logging.js";
|
||||
import { getRoot, parseVersion } from "./ioc.js";
|
||||
import { initServer } from "./server/index.js";
|
||||
import {SimpleIntervalJob, ToadScheduler} from "toad-scheduler";
|
||||
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
|
||||
import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
|
||||
import { parseBool, readJson, sleep } from "./utils.js";
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
@@ -31,18 +27,19 @@ dayjs.extend(timezone);
|
||||
|
||||
const scheduler = new ToadScheduler()
|
||||
|
||||
let output: LogInfo[] = []
|
||||
let output: LogDataPretty[] = []
|
||||
|
||||
const initLogger = getLogger({file: false}, 'init');
|
||||
initLogger.stream().on('log', (log: LogInfo) => {
|
||||
output.unshift(log);
|
||||
output = output.slice(0, 301);
|
||||
const [parentInitLogger, initLoggerStream] = getInitLogger();
|
||||
const initLogger = childLogger(parentInitLogger, 'Init');
|
||||
initLoggerStream.on('data', (log: LogDataPretty) => {
|
||||
output.unshift(log);
|
||||
output = output.slice(0, 301);
|
||||
});
|
||||
|
||||
let logger: Logger;
|
||||
let logger: FoxLogger;
|
||||
|
||||
process.on('uncaughtExceptionMonitor', (err, origin) => {
|
||||
const appError = new ErrorWithCause(`Uncaught exception is crashing the app! :( Type: ${origin}`, {cause: err});
|
||||
const appError = new Error(`Uncaught exception is crashing the app! :( Type: ${origin}`, {cause: err});
|
||||
if(logger !== undefined) {
|
||||
logger.error(appError)
|
||||
} else {
|
||||
@@ -53,7 +50,7 @@ process.on('uncaughtExceptionMonitor', (err, origin) => {
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
|
||||
try {
|
||||
initLogger.debug(`Config Dir ENV: ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`)
|
||||
initLogger.verbose(`Config Dir ENV: ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`)
|
||||
// try to read a configuration file
|
||||
let appConfigFail: Error | undefined = undefined;
|
||||
let config = {};
|
||||
@@ -78,12 +75,15 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
process.env.DEBUG_MODE = b.toString();
|
||||
}
|
||||
|
||||
const root = getRoot(config);
|
||||
await parseVersion();
|
||||
|
||||
const [aLogger, appLoggerStream] = await appLogger(logging)
|
||||
logger = childLogger(aLogger, 'App');
|
||||
|
||||
const root = getRoot({...config, logger});
|
||||
initLogger.info(`Version: ${root.get('version')}`);
|
||||
|
||||
logger = getLogger(logging, 'app');
|
||||
|
||||
initServer(logger, output);
|
||||
initServer(logger, appLoggerStream, output);
|
||||
|
||||
if(process.env.IS_LOCAL === 'true') {
|
||||
logger.info('multi-scrobbler can be run as a background service! See: https://foxxmd.github.io/multi-scrobbler/docs/installation/service');
|
||||
@@ -111,7 +111,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
await client.initScrobbleMonitoring();
|
||||
}
|
||||
|
||||
const scrobbleSources = root.get('sources');//new ScrobbleSources(localUrl, configDir);
|
||||
const scrobbleSources = root.get('sources');
|
||||
await scrobbleSources.buildSourcesFromConfig([]);
|
||||
|
||||
// check ambiguous client/source types like this for now
|
||||
@@ -148,7 +148,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
logger.info('Scheduler started.');
|
||||
|
||||
} catch (e) {
|
||||
const appError = new ErrorWithCause('Exited with uncaught error', {cause: e});
|
||||
const appError = new Error('Exited with uncaught error', {cause: e});
|
||||
if(logger !== undefined) {
|
||||
logger.error(appError);
|
||||
} else {
|
||||
|
||||
+25
-47
@@ -1,44 +1,19 @@
|
||||
import {createContainer} from "iti";
|
||||
import { getVersion } from "@foxxmd/get-version";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { EventEmitter } from "events";
|
||||
import { createContainer } from "iti";
|
||||
import path from "path";
|
||||
import { configDir, projectDir } from "./common/index.js";
|
||||
import { projectDir } from "./common/index.js";
|
||||
import { WildcardEmitter } from "./common/WildcardEmitter.js";
|
||||
import { Notifiers } from "./notifier/Notifiers.js";
|
||||
import ScrobbleClients from "./scrobblers/ScrobbleClients.js";
|
||||
import ScrobbleSources from "./sources/ScrobbleSources.js";
|
||||
import { Notifiers } from "./notifier/Notifiers.js";
|
||||
import {EventEmitter} from "events";
|
||||
import { logPath } from "./common/logging.js";
|
||||
import { WildcardEmitter } from "./common/WildcardEmitter.js";
|
||||
import normalizeUrl from 'normalize-url';
|
||||
import fs from 'fs';
|
||||
import { generateBaseURL } from "./utils.js";
|
||||
|
||||
let version = 'Unknown';
|
||||
let version: string = 'unknown';
|
||||
|
||||
if(process.env.APP_VERSION === undefined) {
|
||||
if(fs.existsSync('./package.json')) {
|
||||
try {
|
||||
const pkg = fs.readFileSync('./package.json') as unknown as string;
|
||||
try {
|
||||
version = JSON.parse(pkg).version || 'unknown'
|
||||
} catch (e) {
|
||||
// don't care
|
||||
}
|
||||
} catch (e) {
|
||||
// don't care
|
||||
}
|
||||
} else if(fs.existsSync('./package-lock.json')) {
|
||||
try {
|
||||
const pkg = fs.readFileSync('./package-lock.json') as unknown as string;
|
||||
try {
|
||||
version = JSON.parse(pkg).version || 'unknown'
|
||||
} catch (e) {
|
||||
// don't care
|
||||
}
|
||||
} catch (e) {
|
||||
// don't care
|
||||
}
|
||||
}
|
||||
process.env.APP_VERSION = version;
|
||||
} else {
|
||||
version = process.env.APP_VERSION;
|
||||
export const parseVersion = async () => {
|
||||
version = await getVersion({priority: ['env', 'git', 'file']});
|
||||
}
|
||||
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
@@ -46,37 +21,40 @@ let root: ReturnType<typeof createRoot>;
|
||||
export interface RootOptions {
|
||||
baseUrl?: string,
|
||||
port?: string | number
|
||||
logger: Logger
|
||||
disableWeb?: boolean
|
||||
}
|
||||
|
||||
const createRoot = (options?: RootOptions) => {
|
||||
const {
|
||||
port = 9078,
|
||||
baseUrl = process.env.BASE_URL,
|
||||
disableWeb: dw
|
||||
} = options || {};
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
let disableWeb = dw;
|
||||
if(disableWeb === undefined) {
|
||||
disableWeb = process.env.DISABLE_WEB === 'true';
|
||||
}
|
||||
|
||||
return createContainer().add({
|
||||
version,
|
||||
configDir: configDir,
|
||||
logDir: logPath,
|
||||
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
|
||||
port: process.env.PORT ?? port,
|
||||
disableWeb,
|
||||
clientEmitter: () => new WildcardEmitter(),
|
||||
sourceEmitter: () => new WildcardEmitter(),
|
||||
notifierEmitter: () => new EventEmitter(),
|
||||
}).add((items) => {
|
||||
const base = normalizeUrl(baseUrl ?? 'http://localhost', {removeSingleSlash: true});
|
||||
const u = new URL(base);
|
||||
let localUrl = u.toString();
|
||||
if(u.port === '' && u.pathname === '/') {
|
||||
localUrl = `${u.origin}:${items.mainPort}`;
|
||||
}
|
||||
const localUrl = generateBaseURL(baseUrl, items.port)
|
||||
return {
|
||||
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, localUrl, items.configDir),
|
||||
sources: () => new ScrobbleSources(items.sourceEmitter, localUrl, items.configDir),
|
||||
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
|
||||
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, localUrl, items.configDir, options.logger),
|
||||
sources: () => new ScrobbleSources(items.sourceEmitter, localUrl, items.configDir, options.logger),
|
||||
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter, options.logger),
|
||||
localUrl,
|
||||
hasDefinedBaseUrl: baseUrl !== undefined,
|
||||
isSubPath: u.pathname !== '/' && u.pathname.length > 0
|
||||
isSubPath: localUrl.pathname !== '/' && localUrl.pathname.length > 0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { GotifyConfig, NtfyConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { mergeArr } from "../utils.js";
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import {
|
||||
AppriseConfig,
|
||||
GotifyConfig,
|
||||
NtfyConfig,
|
||||
WebhookPayload
|
||||
} from "../common/infrastructure/config/health/webhooks.js";
|
||||
|
||||
export abstract class AbstractWebhookNotifier {
|
||||
|
||||
config: GotifyConfig | NtfyConfig
|
||||
config: GotifyConfig | NtfyConfig | AppriseConfig
|
||||
logger: Logger;
|
||||
|
||||
initialized: boolean = false;
|
||||
requiresAuth: boolean = false;
|
||||
authed: boolean = false;
|
||||
|
||||
protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig, logger: Logger) {
|
||||
protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig | AppriseConfig, logger: Logger) {
|
||||
this.config = config;
|
||||
const label = `${type} - ${config.name ?? defaultName}`
|
||||
this.logger = logger.child({labels: [label]}, mergeArr);
|
||||
this.logger = childLogger(logger, label);
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import request, { Request } from "superagent";
|
||||
import { truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import {
|
||||
AppriseConfig,
|
||||
PrioritiesConfig,
|
||||
Priority,
|
||||
WebhookPayload
|
||||
} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
|
||||
const shortKey = truncateStringToLength(10);
|
||||
|
||||
export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
declare config: AppriseConfig;
|
||||
|
||||
priorities: PrioritiesConfig;
|
||||
|
||||
urls: string[];
|
||||
keys: string[];
|
||||
|
||||
constructor(defaultName: string, config: AppriseConfig, logger: Logger) {
|
||||
super('Apprise', defaultName, config, logger);
|
||||
const {
|
||||
urls = [],
|
||||
keys = [],
|
||||
host,
|
||||
} = this.config;
|
||||
if (host === undefined) {
|
||||
throw new Error(`'host' must be defined in configuration for this notification`);
|
||||
}
|
||||
this.urls = Array.isArray(urls) ? urls : [urls];
|
||||
this.keys = Array.isArray(keys) ? keys : [keys];
|
||||
|
||||
if (this.urls.length === 0 && this.keys.length === 0) {
|
||||
this.logger.warn(`No 'urls' or 'keys' were defined! Will assume stateless (POST ${host}/notify) and that you have the ENV 'APPRISE_STATELESS_URLS' set on your Apprise instance`);
|
||||
}
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// check url is correct
|
||||
try {
|
||||
await request.get(this.config.host);
|
||||
} catch (e) {
|
||||
this.logger.error(new Error('Failed to contact Apprise server', {cause: e}));
|
||||
}
|
||||
|
||||
if (this.keys.length > 0) {
|
||||
let anyOk = false;
|
||||
for (const key of this.keys) {
|
||||
try {
|
||||
const resp = await request.get(`${this.config.host}/json/urls/${key}`);
|
||||
if (resp.statusCode === 204) {
|
||||
this.logger.warn(`Details for Config ${shortKey(key)} returned no content. Double check the key is set correctly or that the apprise Config is not empty.`);
|
||||
} else {
|
||||
anyOk = true;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error(`Failed to get details for Config ${shortKey(key)}`, {cause: e}));
|
||||
}
|
||||
}
|
||||
if (!anyOk) {
|
||||
this.logger.error('No Apprise Configs were valid!');
|
||||
this.initialized = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
doNotify = async (payload: WebhookPayload) => {
|
||||
const body: Record<string, any> = {
|
||||
title: payload.title,
|
||||
body: payload.message,
|
||||
type: convertPriorityToType(payload.priority)
|
||||
}
|
||||
|
||||
let anyOk = false;
|
||||
if (this.keys.length > 0) {
|
||||
for (const key of this.keys) {
|
||||
try {
|
||||
const resp = await this.callApi(request.post(`${this.config.host}/notify/${key}`)
|
||||
.type('json')
|
||||
.send(body));
|
||||
anyOk = true;
|
||||
this.logger.verbose(`Pushed notification to Config ${shortKey(key)}`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(new Error(`Failed to push notification for '${payload.title}' to Config ${shortKey(key)}`, {cause: e}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.urls.length > 0 || this.keys.length === 0) {
|
||||
if (this.urls.length > 0) {
|
||||
body.urls = this.urls.join(',')
|
||||
}
|
||||
try {
|
||||
const resp = await this.callApi(request.post(`${this.config.host}/notify`)
|
||||
.type('json')
|
||||
.send(body));
|
||||
anyOk = true;
|
||||
this.logger.verbose(`Pushed notification to URLs`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to push notification for '${payload.title}' to URLs`, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyOk) {
|
||||
this.logger.error(`Failed to push any notifications!`)
|
||||
}
|
||||
}
|
||||
|
||||
callApi = async <T = unknown>(req: Request, retries = 0): Promise<T> => {
|
||||
try {
|
||||
return await req as T;
|
||||
} catch (e) {
|
||||
if (isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout) {
|
||||
throw new UpstreamError('Request failed to due a network issue', {cause: e});
|
||||
} else if (isSuperAgentResponseError(e)) {
|
||||
const {
|
||||
message,
|
||||
status,
|
||||
response: {
|
||||
body: jsonBody = undefined,
|
||||
text = undefined,
|
||||
} = {}
|
||||
} = e;
|
||||
const errorMsgs = [message];
|
||||
if (typeof jsonBody === 'object' && jsonBody.error !== undefined) {
|
||||
errorMsgs.push(jsonBody.error);
|
||||
}
|
||||
throw new UpstreamError(`Apprise API Request failed => (${status}) ${errorMsgs.join(' => ')}`, {response: e.response});
|
||||
} else {
|
||||
throw new Error('Non API Request error encountered', {cause: e});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const convertPriorityToType = (priority?: Priority): 'info' | 'success' | 'warning' | 'failure' => {
|
||||
switch (priority) {
|
||||
case 'info':
|
||||
return 'info';
|
||||
case 'warn':
|
||||
return 'warning';
|
||||
case 'error':
|
||||
return 'failure';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {gotify} from 'gotify';
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { HTTPError } from "got";
|
||||
import { gotify } from 'gotify';
|
||||
import request from 'superagent';
|
||||
import {HTTPError} from "got";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
|
||||
export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
@@ -54,13 +54,13 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
title: payload.title,
|
||||
priority: this.priorities[payload.priority]
|
||||
});
|
||||
this.logger.debug(`Pushed notification.`);
|
||||
this.logger.verbose(`Pushed notification.`);
|
||||
} catch (e: any) {
|
||||
if(e instanceof HTTPError && e.response.statusCode === 401) {
|
||||
this.logger.error(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`);
|
||||
this.logger.warn(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`);
|
||||
this.authed = false;
|
||||
} else {
|
||||
this.logger.error(`Failed to push notification | Error => ${e.message}`);
|
||||
this.logger.warn(`Failed to push notification | Error => ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import winston, {config, format, Logger} from '@foxxmd/winston';
|
||||
import { mergeArr } from "../utils.js";
|
||||
import { GotifyConfig, NtfyConfig, WebhookConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { childLogger, Logger } from '@foxxmd/logging';
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
AppriseConfig,
|
||||
GotifyConfig,
|
||||
NtfyConfig,
|
||||
WebhookConfig,
|
||||
WebhookPayload
|
||||
} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
import { AppriseWebhookNotifier } from "./AppriseWebhookNotifier.js";
|
||||
import { GotifyWebhookNotifier } from "./GotifyWebhookNotifier.js";
|
||||
import { NtfyWebhookNotifier } from "./NtfyWebhookNotifier.js";
|
||||
import {EventEmitter} from "events";
|
||||
|
||||
export class Notifiers {
|
||||
|
||||
@@ -17,12 +23,12 @@ export class Notifiers {
|
||||
clientEmitter: EventEmitter;
|
||||
sourceEmitter: EventEmitter;
|
||||
|
||||
constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter) {
|
||||
constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter, parentLogger: Logger) {
|
||||
this.emitter = emitter;
|
||||
this.clientEmitter = clientEmitter;
|
||||
this.sourceEmitter = sourceEmitter;
|
||||
|
||||
this.logger = winston.loggers.get('app').child({labels: ['Notifiers']}, mergeArr);
|
||||
this.logger = childLogger(parentLogger, 'Notifiers');
|
||||
|
||||
this.sourceEmitter.on('notify', async (payload: WebhookPayload) => {
|
||||
await this.notify(payload);
|
||||
@@ -40,6 +46,9 @@ export class Notifiers {
|
||||
case 'ntfy':
|
||||
webhook = new NtfyWebhookNotifier(defaultName, config as NtfyConfig, this.logger);
|
||||
break;
|
||||
case 'apprise':
|
||||
webhook = new AppriseWebhookNotifier(defaultName, config as AppriseConfig, this.logger);
|
||||
break;
|
||||
default:
|
||||
this.logger.error(`'${config.type}' is not a valid webhook type`);
|
||||
continue;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
import { NtfyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {publish} from 'ntfy';
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { Config, publish } from 'ntfy';
|
||||
import request from "superagent";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import {Config} from "ntfy/interfaces.js";
|
||||
import { NtfyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
|
||||
export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
@@ -65,9 +64,9 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
}
|
||||
}
|
||||
await publish(req);
|
||||
this.logger.debug(`Pushed notification.`);
|
||||
this.logger.verbose(`Pushed notification.`);
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to push notification: ${e.message}`)
|
||||
this.logger.warn(`Failed to push notification: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
comparingMultipleArtists,
|
||||
mergeArr,
|
||||
playObjDataMatch,
|
||||
pollingBackoff,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByOldestPlayDate,
|
||||
} from "../utils.js";
|
||||
import {
|
||||
ARTIST_WEIGHT,
|
||||
Authenticatable,
|
||||
ClientType,
|
||||
DEFAULT_RETRY_MULTIPLIER,
|
||||
DUP_SCORE_THRESHOLD,
|
||||
FormatPlayObjectOptions,
|
||||
INITIALIZED,
|
||||
INITIALIZING,
|
||||
InitState,
|
||||
NOT_INITIALIZED,
|
||||
REFERENCE_WEIGHT,
|
||||
ScrobbledPlayObject,
|
||||
TIME_WEIGHT,
|
||||
TITLE_WEIGHT,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { CommonClientConfig } from "../common/infrastructure/config/client/index.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import {FixedSizeList} from 'fixed-size-list';
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
DeadLetterScrobble,
|
||||
PlayObject,
|
||||
@@ -37,12 +12,31 @@ import {
|
||||
TrackStringOptions,
|
||||
} from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import EventEmitter from "events";
|
||||
import AbstractComponent from "../common/AbstractComponent.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import {
|
||||
ARTIST_WEIGHT,
|
||||
Authenticatable,
|
||||
ClientType,
|
||||
DEFAULT_RETRY_MULTIPLIER,
|
||||
DUP_SCORE_THRESHOLD,
|
||||
FormatPlayObjectOptions,
|
||||
ScrobbledPlayObject,
|
||||
TIME_WEIGHT,
|
||||
TITLE_WEIGHT,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { CommonClientConfig } from "../common/infrastructure/config/client/index.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import {
|
||||
comparingMultipleArtists,
|
||||
playObjDataMatch,
|
||||
pollingBackoff,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByOldestPlayDate,
|
||||
} from "../utils.js";
|
||||
import { messageWithCauses } from "../utils/ErrorUtils.js";
|
||||
import { compareScrobbleArtists, compareScrobbleTracks, normalizeStr } from "../utils/StringUtils.js";
|
||||
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import {nanoid} from "nanoid";
|
||||
import {ErrorWithCause, messageWithCauses} from "pony-cause";
|
||||
import { hasNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import {
|
||||
comparePlayTemporally,
|
||||
temporalAccuracyIsAtLeast,
|
||||
@@ -50,20 +44,14 @@ import {
|
||||
temporalPlayComparisonSummary,
|
||||
} from "../utils/TimeUtils.js";
|
||||
|
||||
export default abstract class AbstractScrobbleClient implements Authenticatable {
|
||||
export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable {
|
||||
|
||||
name: string;
|
||||
type: ClientType;
|
||||
identifier: string;
|
||||
|
||||
#initState: InitState = NOT_INITIALIZED;
|
||||
|
||||
protected MAX_STORED_SCROBBLES = 40;
|
||||
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authed: boolean = false;
|
||||
authFailure?: boolean;
|
||||
protected MAX_INITIAL_SCROBBLES_FETCH = this.MAX_STORED_SCROBBLES;
|
||||
|
||||
#recentScrobblesList: PlayObject[] = [];
|
||||
scrobbledPlayObjs: FixedSizeList<ScrobbledPlayObject>;
|
||||
@@ -86,31 +74,29 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
deadLetterScrobbles: DeadLetterScrobble<PlayObject>[] = [];
|
||||
|
||||
config: CommonClientConfig;
|
||||
logger: Logger;
|
||||
|
||||
notifier: Notifiers;
|
||||
emitter: EventEmitter;
|
||||
|
||||
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
|
||||
super();
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.identifier = `${capitalize(this.type)} - ${name}`;
|
||||
this.logger = logger.child({labels: [this.identifier]}, mergeArr);
|
||||
this.logger = childLogger(logger, this.identifier);
|
||||
this.notifier = notifier;
|
||||
this.emitter = emitter;
|
||||
|
||||
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES);
|
||||
|
||||
const {
|
||||
data: {
|
||||
options: {
|
||||
refreshEnabled = true,
|
||||
checkExistingScrobbles = true,
|
||||
verbose = {},
|
||||
} = {},
|
||||
} = {},
|
||||
} = config;
|
||||
this.config = config;
|
||||
const {
|
||||
options: {
|
||||
refreshEnabled = true,
|
||||
checkExistingScrobbles = true,
|
||||
verbose = {},
|
||||
} = {},
|
||||
} = this.config
|
||||
this.refreshEnabled = refreshEnabled;
|
||||
this.checkExistingScrobbles = checkExistingScrobbles;
|
||||
|
||||
@@ -145,70 +131,29 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
return this.#recentScrobblesList;
|
||||
}
|
||||
|
||||
get initialized() {
|
||||
return this.#initState === INITIALIZED;
|
||||
}
|
||||
protected async postInitialize(): Promise<void> {
|
||||
const {
|
||||
options: {
|
||||
refreshInitialCount= this.MAX_INITIAL_SCROBBLES_FETCH
|
||||
} = {}
|
||||
} = this.config;
|
||||
|
||||
set initialized(val) {
|
||||
// @ts-expect-error TS(2367): This condition will always return 'false' since th... Remove this comment to see the full error message
|
||||
if(val === INITIALIZING) {
|
||||
this.#initState = INITIALIZING;
|
||||
// @ts-expect-error TS(2367): This condition will always return 'false' since th... Remove this comment to see the full error message
|
||||
} else if(val === true || val === INITIALIZED) {
|
||||
this.#initState = INITIALIZED;
|
||||
} else {
|
||||
this.#initState = NOT_INITIALIZED;
|
||||
let initialLimit = refreshInitialCount;
|
||||
if(refreshInitialCount > this.MAX_INITIAL_SCROBBLES_FETCH) {
|
||||
this.logger.warn(`Defined initial scrobbles count (${refreshInitialCount}) higher than maximum allowed (${this.MAX_INITIAL_SCROBBLES_FETCH}). Will use max instead.`);
|
||||
initialLimit = this.MAX_INITIAL_SCROBBLES_FETCH;
|
||||
}
|
||||
}
|
||||
|
||||
get initializing() {
|
||||
return this.#initState === INITIALIZING;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if init stage is required
|
||||
initialize = async () => {
|
||||
this.initialized = true;
|
||||
this.logger.info('Initialized');
|
||||
return true;
|
||||
this.logger.verbose(`Fetching up to ${initialLimit} initial scrobbles...`);
|
||||
await this.refreshScrobbles(initialLimit);
|
||||
}
|
||||
|
||||
authGated = () => {
|
||||
return this.requiresAuth && !this.authed;
|
||||
}
|
||||
|
||||
canTryAuth = () => {
|
||||
return this.authGated() && this.authFailure !== true;
|
||||
}
|
||||
|
||||
protected doAuthentication = async (): Promise<boolean> => {
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async () => {
|
||||
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;
|
||||
this.logger.error(new ErrorWithCause(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e}));
|
||||
}
|
||||
}
|
||||
|
||||
isReady = async () => {
|
||||
return this.initialized && !this.authGated();
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
refreshScrobbles = async (limit?: number) => {
|
||||
this.logger.debug('Scrobbler does not have refresh function implemented!');
|
||||
}
|
||||
|
||||
public abstract alreadyScrobbled(playObj: PlayObject, log?: boolean): Promise<boolean>;
|
||||
scrobblesLastCheckedAt = () => {
|
||||
return this.lastScrobbleCheck;
|
||||
}
|
||||
scrobblesLastCheckedAt = () => this.lastScrobbleCheck
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => {
|
||||
this.logger.warn('formatPlayObj should be defined by concrete class!');
|
||||
@@ -246,9 +191,7 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES, this.scrobbledPlayObjs.data.filter(x => this.timeFrameIsValid(x.play)[0])) ;
|
||||
}
|
||||
|
||||
getScrobbledPlays = () => {
|
||||
return this.scrobbledPlayObjs.data.map(x => x.scrobble);
|
||||
}
|
||||
getScrobbledPlays = () => this.scrobbledPlayObjs.data.map(x => x.scrobble)
|
||||
|
||||
findExistingSubmittedPlayObj = (playObj: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => {
|
||||
const {
|
||||
@@ -310,7 +253,7 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
}
|
||||
|
||||
let existingScrobble;
|
||||
let closestMatch: {score: number, breakdowns: string[], confidence: string, scrobble?: PlayObject} = {score: 0, breakdowns: [], confidence: 'None'};
|
||||
let closestMatch: {score: number, breakdowns: string[], confidence: string, scrobble?: PlayObject} = {score: 0, breakdowns: [], confidence: 'No existing scrobble matched with a score higher than 0'};
|
||||
|
||||
// then check if we have already recorded this
|
||||
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
|
||||
@@ -392,7 +335,7 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
artistBreakdown = `Artist: (${artistMatch.toFixed(2)} + Whole Match Bonus ${artistWholeMatchBonus.toFixed(2)}) * (${ARTIST_WEIGHT} + Whole Match Bonus 0.05) = ${artistScore.toFixed(2)}`;
|
||||
}
|
||||
|
||||
let scoreBreakdowns = [
|
||||
const scoreBreakdowns = [
|
||||
//`Reference: ${(referenceMatch ? 1 : 0)} * ${REFERENCE_WEIGHT} = ${referenceScore.toFixed(2)}`,
|
||||
artistBreakdown,
|
||||
`Title: ${titleMatch.toFixed(2)} * ${TITLE_WEIGHT} = ${titleScore.toFixed(2)}`,
|
||||
@@ -419,9 +362,13 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
}
|
||||
|
||||
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
|
||||
const closestScrobble = `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.confidence}`;
|
||||
this.logger.debug(`${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`, {leaf: ['Dupe Check']});
|
||||
if (this.verboseOptions.match.confidenceBreakdown === true) {
|
||||
const closestScrobbleParts: string[] = [];
|
||||
if(closestMatch.scrobble !== undefined) {
|
||||
closestScrobbleParts.push(`Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)}`);
|
||||
}
|
||||
closestScrobbleParts.push(closestMatch.confidence);
|
||||
this.logger.debug(`${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobbleParts.join(' => ')}`, {leaf: ['Dupe Check']});
|
||||
if (this.verboseOptions.match.confidenceBreakdown === true && closestMatch.breakdowns.length > 0) {
|
||||
this.logger.debug(`Breakdown:
|
||||
${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
}
|
||||
@@ -450,9 +397,9 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
protected abstract doScrobble(playObj: PlayObject): Promise<PlayObject>
|
||||
|
||||
public abstract playToClientPayload(playObject: PlayObject): object
|
||||
|
||||
|
||||
initScrobbleMonitoring = async () => {
|
||||
if(!this.initialized) {
|
||||
if(!this.isUsable()) {
|
||||
if(this.initializing) {
|
||||
this.logger.warn(`Cannot start scrobble processing because client is still initializing`);
|
||||
return;
|
||||
@@ -479,7 +426,7 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
}
|
||||
}
|
||||
|
||||
if(!(await this.isReady())) {
|
||||
if(!this.isReady()) {
|
||||
this.logger.warn(`Cannot start scrobble processing because client is not ready`);
|
||||
return;
|
||||
}
|
||||
@@ -517,11 +464,11 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
if(!this.initialized) {
|
||||
this.logger.warn('Stopping scrobble processing due to client no longer being initialized.');
|
||||
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and client is no longer initialized, stopping processing!. | Error: ${e.message}`, priority: 'error'});
|
||||
if(!this.isUsable()) {
|
||||
this.logger.warn('Stopping scrobble processing due to client no longer usable.');
|
||||
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and client is no longer usable, stopping processing!. | Error: ${e.message}`, priority: 'error'});
|
||||
break;
|
||||
} else if (this.requiresAuth && !this.authed) {
|
||||
} else if (this.authGated()) {
|
||||
this.logger.warn('Stopping scrobble processing due to client no longer being authenticated.');
|
||||
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and client is no longer authenticated, stopping processing!. | Error: ${e.message}`, priority: 'error'});
|
||||
break;
|
||||
@@ -581,7 +528,7 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
if (this.lastScrobbleCheck.unix() < this.getLatestQueuePlayDate().unix()) {
|
||||
await this.refreshScrobbles();
|
||||
}
|
||||
const currQueuedPlay = this.queuedScrobbles[0];
|
||||
const currQueuedPlay = this.queuedScrobbles.shift();
|
||||
const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(currQueuedPlay.play);
|
||||
if (timeFrameValid && !(await this.alreadyScrobbled(currQueuedPlay.play))) {
|
||||
try {
|
||||
@@ -591,22 +538,16 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
} catch (e) {
|
||||
if (e instanceof UpstreamError && e.showStopper === false) {
|
||||
this.addDeadLetterScrobble(currQueuedPlay, e);
|
||||
this.logger.warn(new ErrorWithCause(`Could not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`, {cause: e}));
|
||||
this.logger.warn(new Error(`Could not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`, {cause: e}));
|
||||
} else {
|
||||
const processError = new ErrorWithCause('Error occurred while trying to scrobble', {cause: e});
|
||||
//this.logger.error(processError);
|
||||
throw processError;
|
||||
this.queuedScrobbles.unshift(currQueuedPlay);
|
||||
throw new Error('Error occurred while trying to scrobble', {cause: e});
|
||||
}
|
||||
}
|
||||
} else if (!timeFrameValid) {
|
||||
this.logger.debug(`Will not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' because it ${timeFrameValidLog}`);
|
||||
}
|
||||
// processing play may have changed index while we were scrobbling
|
||||
const pIndex = this.queuedScrobbles.findIndex(x => x.id === currQueuedPlay.id);
|
||||
if (pIndex !== -1) {
|
||||
this.emitEvent('scrobbleDequeued', {queuedScrobble: currQueuedPlay})
|
||||
this.queuedScrobbles.splice(pIndex, 1);
|
||||
}
|
||||
this.emitEvent('scrobbleDequeued', {queuedScrobble: currQueuedPlay})
|
||||
}
|
||||
await sleep(this.scrobbleSleep);
|
||||
}
|
||||
@@ -633,7 +574,7 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
options: {
|
||||
deadLetterRetries = 1
|
||||
} = {}
|
||||
} = this.config.data;
|
||||
} = this.config;
|
||||
|
||||
const retries = attemptWithRetries ?? deadLetterRetries;
|
||||
|
||||
@@ -680,7 +621,7 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
deadScrobble.retries++;
|
||||
deadScrobble.error = messageWithCauses(e);
|
||||
deadScrobble.lastRetry = dayjs();
|
||||
this.logger.error(new ErrorWithCause(`Could not scrobble ${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' due to error`, {cause: e}));
|
||||
this.logger.error(new Error(`Could not scrobble ${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' due to error`, {cause: e}));
|
||||
this.deadLetterScrobbles[deadScrobbleIndex] = deadScrobble;
|
||||
return [false, deadScrobble];
|
||||
} finally {
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
playObjDataMatch,
|
||||
removeUndefinedKeys,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByOldestPlayDate,
|
||||
} from "../utils.js";
|
||||
import LastfmApiClient from "../common/vendor/LastfmApiClient.js";
|
||||
import { FormatPlayObjectOptions, INITIALIZING, ScrobbledPlayObject } from "../common/infrastructure/Atomic.js";
|
||||
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
|
||||
import {TrackScrobblePayload, TrackScrobbleResponse, UserGetRecentTracksResponse} from "lastfm-node-client";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { PlayObject, TrackStringOptions } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import EventEmitter from "events";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { TrackScrobbleResponse, UserGetRecentTracksResponse } from "lastfm-node-client";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { getScrobbleTsSOCDate } from "../utils/TimeUtils.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
|
||||
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
|
||||
import LastfmApiClient from "../common/vendor/LastfmApiClient.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
|
||||
export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
@@ -32,25 +22,17 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
constructor(name: any, config: LastfmClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
|
||||
super('lastfm', name, config, notifier, emitter, logger);
|
||||
// @ts-ignore
|
||||
this.api = new LastfmApiClient(name, config.data, options)
|
||||
// @ts-expect-error sloppy data structure assign
|
||||
this.api = new LastfmApiClient(name, config.data, {...options, logger})
|
||||
// https://www.last.fm/api/show/user.getRecentTracks
|
||||
this.MAX_INITIAL_SCROBBLES_FETCH = 200;
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options);
|
||||
|
||||
initialize = async () => {
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
try {
|
||||
const result = await this.api.initialize();
|
||||
this.initialized = true;
|
||||
this.logger.info('Initialized');
|
||||
} catch (e) {
|
||||
this.initialized = false;
|
||||
this.logger.warn(new ErrorWithCause('Initialization failed', {cause: e}));
|
||||
}
|
||||
|
||||
return this.initialized;
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
await this.api.initialize();
|
||||
return true;
|
||||
}
|
||||
|
||||
doAuthentication = async () => {
|
||||
@@ -64,13 +46,13 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
refreshScrobbles = async (limit = this.MAX_STORED_SCROBBLES) => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({
|
||||
user: this.api.user,
|
||||
sk: this.api.client.sessionKey,
|
||||
limit: this.MAX_STORED_SCROBBLES,
|
||||
limit: limit,
|
||||
extended: true
|
||||
}));
|
||||
const {
|
||||
@@ -131,58 +113,10 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
return track.toLocaleLowerCase().trim();
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
|
||||
return (await this.existingScrobble(playObj)) !== undefined;
|
||||
}
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
|
||||
|
||||
public playToClientPayload(playObj: PlayObject): TrackScrobblePayload {
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
album,
|
||||
albumArtists = [],
|
||||
track,
|
||||
duration,
|
||||
playDate,
|
||||
meta: {
|
||||
brainz: {
|
||||
track: mbid
|
||||
} = {},
|
||||
} = {}
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
let artist: string;
|
||||
if (artists.length === 0) {
|
||||
artist = "";
|
||||
} else {
|
||||
artist = artists[0];
|
||||
}
|
||||
|
||||
const rawPayload: TrackScrobblePayload = {
|
||||
artist: artist,
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: getScrobbleTsSOCDate(playObj).unix(),
|
||||
mbid,
|
||||
};
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
if (albumArtists.length > 0) {
|
||||
rawPayload.albumArtist = albumArtists[0];
|
||||
}
|
||||
|
||||
// I don't know if its lastfm-node-client building the request params incorrectly
|
||||
// or the last.fm api not handling the params correctly...
|
||||
//
|
||||
// ...but in either case if any of the below properties is undefined (possibly also null??)
|
||||
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
|
||||
// so remove all undefined keys from the object before passing to the api client
|
||||
return removeUndefinedKeys(rawPayload);
|
||||
public playToClientPayload(playObject: PlayObject): object {
|
||||
return this.api.playToClientPayload(playObject);
|
||||
}
|
||||
|
||||
doScrobble = async (playObj: PlayObject) => {
|
||||
@@ -195,7 +129,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const scrobblePayload = this.playToClientPayload(playObj);
|
||||
const scrobblePayload = this.api.playToClientPayload(playObj);
|
||||
|
||||
try {
|
||||
const response = await this.api.callApi<TrackScrobbleResponse>((client: any) => client.trackScrobble(
|
||||
@@ -221,7 +155,6 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
} = {},
|
||||
} = response;
|
||||
if(code === 5) {
|
||||
this.initialized = false;
|
||||
throw new UpstreamError('LastFM API reported daily scrobble limit exceeded! 😬 Disabling client', {showStopper: true});
|
||||
}
|
||||
if (newFromSource) {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import { FormatPlayObjectOptions, INITIALIZING } from "../common/infrastructure/Atomic.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
|
||||
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js";
|
||||
import { ListenbrainzApiClient, ListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { PlayObject, TrackStringOptions } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import EventEmitter from "events";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
|
||||
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
@@ -23,28 +22,29 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
|
||||
super('listenbrainz', name, config, notifier, emitter, logger);
|
||||
this.api = new ListenbrainzApiClient(name, config.data);
|
||||
this.api = new ListenbrainzApiClient(name, config.data, {logger: this.logger});
|
||||
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-user-(user_name)-listens
|
||||
// 1000 is way too high. maxing at 100
|
||||
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
|
||||
|
||||
initialize = async () => {
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
if(this.config.data.token === undefined) {
|
||||
this.logger.error('Could not initialize, must provide a User Token');
|
||||
this.initialized = false;
|
||||
} else {
|
||||
try {
|
||||
await this.api.testConnection();
|
||||
this.initialized = true;
|
||||
this.logger.info('Initialized');
|
||||
} catch (e) {
|
||||
this.logger.warn(new ErrorWithCause('Could not initialize', {cause: e}));
|
||||
this.initialized = false;
|
||||
}
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
data: {
|
||||
token,
|
||||
} = {}
|
||||
} = this.config;
|
||||
if (token === undefined) {
|
||||
throw new Error('Must provide a User Token');
|
||||
}
|
||||
return this.initialized;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.api.testConnection();
|
||||
return true;
|
||||
}
|
||||
|
||||
doAuthentication = async () => {
|
||||
@@ -59,10 +59,10 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
refreshScrobbles = async (limit = this.MAX_STORED_SCROBBLES) => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const resp = await this.api.getRecentlyPlayed(this.MAX_STORED_SCROBBLES);
|
||||
const resp = await this.api.getRecentlyPlayed(limit);
|
||||
this.logger.debug(`Found ${resp.length} recent scrobbles`);
|
||||
this.recentScrobbles = resp;
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
@@ -77,9 +77,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
|
||||
return (await this.existingScrobble(playObj)) !== undefined;
|
||||
}
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
|
||||
|
||||
public playToClientPayload(playObj: PlayObject): ListenPayload {
|
||||
return ListenbrainzApiClient.playToListenPayload(playObj);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import request, {ResponseError, SuperAgentRequest} from 'superagent';
|
||||
import dayjs from 'dayjs';
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import compareVersions from 'compare-versions';
|
||||
import { sleep, parseRetryAfterSecsFromObj } from "../utils.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, INITIALIZING } from "../common/infrastructure/Atomic.js";
|
||||
import dayjs from 'dayjs';
|
||||
import EventEmitter from "events";
|
||||
import normalizeUrl from "normalize-url";
|
||||
import request, { SuperAgentRequest } from 'superagent';
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
|
||||
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import {
|
||||
getMalojaResponseError,
|
||||
isMalojaAPIErrorBody,
|
||||
@@ -19,23 +23,16 @@ import {
|
||||
MalojaV2ScrobbleData,
|
||||
MalojaV3ScrobbleData,
|
||||
} from "../common/vendor/maloja/interfaces.js";
|
||||
import { PlayObject, TrackStringOptions } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import EventEmitter from "events";
|
||||
import normalizeUrl from "normalize-url";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import { parseRetryAfterSecsFromObj, sleep } from "../utils.js";
|
||||
import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from "../utils/TimeUtils.js";
|
||||
import e from "express";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js";
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
|
||||
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
|
||||
|
||||
export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
requiresAuth = true;
|
||||
serverIsHealthy = false;
|
||||
serverVersion: any;
|
||||
webUrl: string;
|
||||
|
||||
@@ -43,14 +40,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
|
||||
super('maloja', name, config, notifier, emitter,logger);
|
||||
const {url, apiKey} = config.data;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config! Client will most likely fail when trying to scrobble");
|
||||
}
|
||||
if (url === undefined) {
|
||||
throw new Error("Missing 'url' for Maloja config");
|
||||
}
|
||||
this.webUrl = normalizeUrl(url);
|
||||
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
@@ -106,7 +96,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
duration = mDuration;
|
||||
time = mTime;
|
||||
}
|
||||
let artistStrings = artists.reduce((acc: any, curr: any) => {
|
||||
const artistStrings = artists.reduce((acc: any, curr: any) => {
|
||||
let aString;
|
||||
if (typeof curr === 'string') {
|
||||
aString = curr;
|
||||
@@ -170,7 +160,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, {cause: e})
|
||||
}
|
||||
} else {
|
||||
throw new ErrorWithCause('Unexpected error occurred during API call', {cause : e});
|
||||
throw new Error('Unexpected error occurred during API call', {cause : e});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,8 +179,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
} = serverInfoResp;
|
||||
|
||||
if (statusCode >= 300) {
|
||||
this.logger.info(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
|
||||
return false;
|
||||
throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
|
||||
}
|
||||
|
||||
this.logger.info('Communication test succeeded.');
|
||||
@@ -208,8 +197,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error(new ErrorWithCause('Communication test failed', {cause: e}));
|
||||
return false;
|
||||
throw new Error('Communication test failed', {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,38 +219,42 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
} = serverInfoResp;
|
||||
|
||||
if (statusCode >= 300) {
|
||||
return [false, `Server responded with NOT OK status: ${statusCode}`];
|
||||
throw new Error(`Server responded with NOT OK status: ${statusCode}`);
|
||||
}
|
||||
|
||||
if(rebuildinprogress) {
|
||||
return [false, 'Server is rebuilding database'];
|
||||
throw new Error(`Server is rebuilding database`);
|
||||
}
|
||||
|
||||
if(!healthy) {
|
||||
return [false, 'Server responded that it is not healthy'];
|
||||
throw new Error('Server responded that it is not healthy');
|
||||
}
|
||||
|
||||
return [true];
|
||||
return true
|
||||
} catch (e) {
|
||||
this.logger.error(new ErrorWithCause('Unexpected error encountered while testing server health', {cause: e}));
|
||||
throw e;
|
||||
throw new Error('Error encountered while testing server health', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// just checking that we can get a connection
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
const result = await this.testConnection();
|
||||
this.initialized = result;
|
||||
if(result) {
|
||||
this.logger.info('Initialized');
|
||||
} else {
|
||||
this.logger.warn('Could not initialize');
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {data: {url, apiKey} = {}} = this.config;
|
||||
if (apiKey === undefined) {
|
||||
throw new Error("'apiKey' not found in config!");
|
||||
}
|
||||
return this.initialized;
|
||||
if (url === undefined) {
|
||||
throw new Error("Missing 'url' for Maloja config");
|
||||
}
|
||||
this.webUrl = normalizeUrl(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.testConnection();
|
||||
await this.testHealth();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
doAuthentication = async () => {
|
||||
|
||||
const {url, apiKey} = this.config.data;
|
||||
@@ -293,7 +285,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
} catch (e) {
|
||||
if(e instanceof UpstreamError) {
|
||||
if(e.cause.status === 403) {
|
||||
if((e?.cause as any)?.status === 403) {
|
||||
// may be an older version that doesn't support auth readiness before db upgrade
|
||||
// and if it was before api was accessible during db build then test would fail during testConnection()
|
||||
if(compareVersions(this.serverVersion, '2.12.19') < 0) {
|
||||
@@ -307,32 +299,11 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
}
|
||||
|
||||
isReady = async () => {
|
||||
if (this.serverIsHealthy) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const [isHealthy, status] = await this.testHealth();
|
||||
if (!isHealthy) {
|
||||
this.logger.error(`Server is not ready: ${status}`);
|
||||
this.serverIsHealthy = false;
|
||||
} else {
|
||||
this.logger.info('Server reported database is built and status is healthy');
|
||||
this.serverIsHealthy = true;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(new ErrorWithCause(`Testing server health failed due to an unexpected error`, {cause: e}));
|
||||
this.serverIsHealthy = false;
|
||||
}
|
||||
return this.serverIsHealthy
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
refreshScrobbles = async (limit = this.MAX_STORED_SCROBBLES) => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const {url} = this.config.data;
|
||||
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=${this.MAX_STORED_SCROBBLES}`));
|
||||
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?perpage=${limit}`));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
@@ -375,9 +346,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
return lowerTitle;
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: any, log = false) => {
|
||||
return (await this.existingScrobble(playObj)) !== undefined;
|
||||
}
|
||||
alreadyScrobbled = async (playObj: any, log = false) => (await this.existingScrobble(playObj)) !== undefined
|
||||
|
||||
public playToClientPayload(playObj: PlayObject): MalojaScrobbleRequestData {
|
||||
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
createAjvFactory,
|
||||
mergeArr,
|
||||
playObjDataMatch,
|
||||
readJson,
|
||||
returnDuplicateStrings,
|
||||
validateJson,
|
||||
} from "../utils.js";
|
||||
import MalojaScrobbler from "./MalojaScrobbler.js";
|
||||
import LastfmScrobbler from "./LastfmScrobbler.js";
|
||||
/* eslint-disable no-case-declarations */
|
||||
import { childLogger, Logger } from '@foxxmd/logging';
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { clientTypes, ConfigMeta } from "../common/infrastructure/Atomic.js";
|
||||
import { AIOConfig } from "../common/infrastructure/config/aioConfig.js";
|
||||
import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js";
|
||||
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
|
||||
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js";
|
||||
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
|
||||
import * as aioSchema from '../common/schema/aio-client.json';
|
||||
import * as clientSchema from '../common/schema/client.json';
|
||||
import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js";
|
||||
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
|
||||
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import {EventEmitter} from "events";
|
||||
import winston, {Logger} from '@foxxmd/winston';
|
||||
import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js";
|
||||
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString } from "../../core/StringUtils.js";
|
||||
import { WildcardEmitter } from "../common/WildcardEmitter.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import { joinedUrl, readJson, validateJson, } from "../utils.js";
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import LastfmScrobbler from "./LastfmScrobbler.js";
|
||||
import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js";
|
||||
import MalojaScrobbler from "./MalojaScrobbler.js";
|
||||
|
||||
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
|
||||
|
||||
@@ -37,31 +28,27 @@ export default class ScrobbleClients {
|
||||
clients: (MalojaScrobbler | LastfmScrobbler)[] = [];
|
||||
logger: Logger;
|
||||
configDir: string;
|
||||
localUrl: string;
|
||||
localUrl: URL;
|
||||
|
||||
emitter: WildcardEmitter;
|
||||
|
||||
sourceEmitter: WildcardEmitter;
|
||||
|
||||
constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, localUrl: string, configDir: string) {
|
||||
constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, localUrl: URL, configDir: string, parentLogger: Logger) {
|
||||
this.emitter = emitter;
|
||||
this.sourceEmitter = sourceEmitter;
|
||||
this.configDir = configDir;
|
||||
this.localUrl = localUrl;
|
||||
this.logger = winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
|
||||
this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
|
||||
|
||||
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
|
||||
await this.scrobble(payload.data, payload.options);
|
||||
});
|
||||
}
|
||||
|
||||
getByName = (name: any) => {
|
||||
return this.clients.find(x => x.name === name);
|
||||
}
|
||||
getByName = (name: any) => this.clients.find(x => x.name === name)
|
||||
|
||||
getByType = (type: any) => {
|
||||
return this.clients.filter(x => x.type === type);
|
||||
}
|
||||
getByType = (type: any) => this.clients.filter(x => x.type === type)
|
||||
|
||||
async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> {
|
||||
let clients: AbstractScrobbleClient[];
|
||||
@@ -88,7 +75,7 @@ export default class ScrobbleClients {
|
||||
}
|
||||
|
||||
buildClientsFromConfig = async (notifier: Notifiers) => {
|
||||
let configs: ParsedConfig[] = [];
|
||||
const configs: ParsedConfig[] = [];
|
||||
|
||||
let configFile;
|
||||
try {
|
||||
@@ -127,7 +114,7 @@ export default class ScrobbleClients {
|
||||
}
|
||||
|
||||
for (const clientType of clientTypes) {
|
||||
let defaultConfigureAs = 'client';
|
||||
const defaultConfigureAs = 'client';
|
||||
switch (clientType) {
|
||||
case 'maloja':
|
||||
// env builder for single user mode
|
||||
@@ -142,7 +129,6 @@ export default class ScrobbleClients {
|
||||
configureAs: 'client',
|
||||
data: {
|
||||
url,
|
||||
// @ts-ignore
|
||||
apiKey
|
||||
}
|
||||
})
|
||||
@@ -162,8 +148,7 @@ export default class ScrobbleClients {
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: 'client',
|
||||
// @ts-ignore
|
||||
data: {...lfm, redirectUri: lfm.redirectUri ?? `${this.localUrl}/lastfm/callback`}
|
||||
data: {...lfm, redirectUri: lfm.redirectUri ?? joinedUrl(this.localUrl, 'lastfm/callback').toString()}
|
||||
})
|
||||
}
|
||||
break;
|
||||
@@ -180,7 +165,6 @@ export default class ScrobbleClients {
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: 'client',
|
||||
// @ts-ignore
|
||||
data: lz
|
||||
})
|
||||
}
|
||||
@@ -211,7 +195,7 @@ export default class ScrobbleClients {
|
||||
for(const [i,rawConf] of rawClientConfigs.entries()) {
|
||||
try {
|
||||
const validConfig = validateJson<ClientConfig>(rawConf, clientSchema, this.logger);
|
||||
// @ts-ignore
|
||||
// @ts-expect-error configureAs should exist
|
||||
const {configureAs = defaultConfigureAs} = validConfig;
|
||||
if (configureAs === 'client') {
|
||||
const parsedConfig: ParsedConfig = {
|
||||
|
||||
+103
-80
@@ -1,70 +1,60 @@
|
||||
import {ExpressWithAsync} from "@awaitjs/express";
|
||||
import { getRoot } from "../ioc.js";
|
||||
import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js";
|
||||
import AbstractSource from "../sources/AbstractSource.js";
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { LogDataPretty, Logger, LogLevel } from "@foxxmd/logging";
|
||||
import bsseDef from 'better-sse';
|
||||
import bodyParser from "body-parser";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { PassThrough } from "node:stream";
|
||||
import { Transform } from "stream";
|
||||
import {
|
||||
ClientStatusData,
|
||||
DeadLetterScrobble,
|
||||
LogInfo,
|
||||
LogInfoJson,
|
||||
LogLevel,
|
||||
LeveledLogData,
|
||||
LogOutputConfig,
|
||||
PlayObject,
|
||||
SOURCE_SOT,
|
||||
SourceStatusData,
|
||||
} from "../../core/Atomic.js";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import { formatLogToHtml, getLogger, isLogLevelMinLevel, isLogLineMinLevel } from "../common/logging.js";
|
||||
import {MESSAGE} from "triple-beam";
|
||||
import {Transform} from "stream";
|
||||
//import {createSession} from "better-sse";
|
||||
import * as bsse from 'better-sse';
|
||||
import bsseDef from 'better-sse';
|
||||
import { setupTautulliRoutes } from "./tautulliRoutes.js";
|
||||
import { setupPlexRoutes } from "./plexRoutes.js";
|
||||
import { setupJellyfinRoutes } from "./jellyfinRoutes.js";
|
||||
import { setupDeezerRoutes } from "./deezerRoutes.js";
|
||||
import { setupAuthRoutes } from "./auth.js";
|
||||
import { ExpressHandler, ScrobbledPlayObject } from "../common/infrastructure/Atomic.js";
|
||||
import MemorySource from "../sources/MemorySource.js";
|
||||
import { capitalize } from "../../core/StringUtils.js";
|
||||
import {source} from "common-tags";
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic.js";
|
||||
import { getRoot } from "../ioc.js";
|
||||
import AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.js";
|
||||
import AbstractSource from "../sources/AbstractSource.js";
|
||||
import MemorySource from "../sources/MemorySource.js";
|
||||
import { sortByNewestPlayDate } from "../utils.js";
|
||||
import bodyParser from "body-parser";
|
||||
import { setupAuthRoutes } from "./auth.js";
|
||||
import { setupDeezerRoutes } from "./deezerRoutes.js";
|
||||
import { setupJellyfinRoutes } from "./jellyfinRoutes.js";
|
||||
import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js";
|
||||
import { setupPlexRoutes } from "./plexRoutes.js";
|
||||
import { setupTautulliRoutes } from "./tautulliRoutes.js";
|
||||
import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js";
|
||||
import {FixedSizeList} from 'fixed-size-list';
|
||||
|
||||
const maxBufferSize = 300;
|
||||
const output: {
|
||||
[key in LogLevel]: FixedSizeList<LogInfo>
|
||||
} = {
|
||||
'debug': new FixedSizeList<LogInfo>(maxBufferSize),
|
||||
'verbose': new FixedSizeList<LogInfo>(maxBufferSize),
|
||||
'info': new FixedSizeList<LogInfo>(maxBufferSize),
|
||||
'warn': new FixedSizeList<LogInfo>(maxBufferSize),
|
||||
'error': new FixedSizeList<LogInfo>(maxBufferSize),
|
||||
const output: Record<number, FixedSizeList<LogDataPretty>> = {};
|
||||
|
||||
const createAddToLogBuffer = (levelMap: {[p: number]: string}) => (log: LogDataPretty) => {
|
||||
output[log.level].add({...log, levelLabel: levelMap[log.level]});
|
||||
}
|
||||
|
||||
const addToLogBuffer = (log: LogInfo) => {
|
||||
output[log.level as LogLevel].add(log);
|
||||
}
|
||||
|
||||
const getLogs = (minLevel: LogLevel, limit: number = maxBufferSize, sort: 'asc' | 'desc' = 'desc'): LogInfo[] => {
|
||||
const allLogs: LogInfo[][] = [];
|
||||
const getLogs = (minLevel: number, limit: number = maxBufferSize, sort: 'asc' | 'desc' = 'desc'): LeveledLogData[] => {
|
||||
const allLogs: LeveledLogData[][] = [];
|
||||
for(const level of Object.keys(output)) {
|
||||
if(isLogLevelMinLevel(level as LogLevel, minLevel)) {
|
||||
if(Number.parseInt(level) >= minLevel) {
|
||||
allLogs.push(output[level].data);
|
||||
}
|
||||
}
|
||||
if(sort === 'desc') {
|
||||
return allLogs.flat(1).sort((a, b) => b.id - a.id).slice(0, limit);
|
||||
return allLogs.flat(1).sort((a, b) => b.time - a.time).slice(0, limit);
|
||||
}
|
||||
return allLogs.flat(1).sort((a, b) => a.id - b.id).slice(0, limit);
|
||||
return allLogs.flat(1).sort((a, b) => a.time - b.time).slice(0, limit);
|
||||
}
|
||||
|
||||
const availableLevels: LogLevel[] = ['error', 'warn', 'info', 'verbose', 'debug'];
|
||||
export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = []) => {
|
||||
for(const level of Object.keys(logger.levels.labels)) {
|
||||
output[level] = new FixedSizeList<LeveledLogData>(maxBufferSize);
|
||||
}
|
||||
|
||||
export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput: LogInfo[] = []) => {
|
||||
const addToLogBuffer = createAddToLogBuffer(logger.levels.labels);
|
||||
for(const log of initialLogOutput) {
|
||||
addToLogBuffer(log);
|
||||
}
|
||||
@@ -81,7 +71,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
let logObjectStream: Transform;
|
||||
try {
|
||||
logObjectStream = new Transform({
|
||||
transform(chunk, e, cb) {
|
||||
transform: (chunk, e, cb) => {
|
||||
cb(null, chunk)
|
||||
},
|
||||
objectMode: true,
|
||||
@@ -91,11 +81,10 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
const appLogger = getLogger({}, 'app');
|
||||
appLogger.stream().on('log', (log: LogInfo) => {
|
||||
appLoggerStream.on('data', (log: LogDataPretty) => {
|
||||
addToLogBuffer(log);
|
||||
if(isLogLineMinLevel(log, logConfig.level)) {
|
||||
logObjectStream.write({message: log[MESSAGE], level: log.level});
|
||||
if(log.level >= logger.levels.values[logConfig.level]) {
|
||||
logObjectStream.write({message: log.line, level: log.level, levelLabel: logger.levels.labels[log.level]});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,13 +98,13 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
const sourceRequiredMiddle = sourceMiddleFunc(true);
|
||||
|
||||
const setLogWebSettings: ExpressHandler = async (req, res, next) => {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error logLevel not part of session
|
||||
const sessionLevel: LogLevel | undefined = req.session.logLevel as LogLevel | undefined;
|
||||
if(sessionLevel !== undefined && logConfig.level !== sessionLevel) {
|
||||
logConfig.level = sessionLevel;
|
||||
}
|
||||
// @ts-ignore
|
||||
const sessionLimit: number | undefined = req.session.limit as Number | undefined;
|
||||
// @ts-expect-error limit not part of session
|
||||
const sessionLimit: number | undefined = req.session.limit as number | undefined;
|
||||
if(sessionLimit !== undefined && logConfig.limit !== sessionLimit) {
|
||||
logConfig.limit = sessionLimit;
|
||||
}
|
||||
@@ -128,21 +117,19 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
});
|
||||
|
||||
app.get('/api/logs', setLogWebSettings, async (req, res) => {
|
||||
const slicedLog = getLogs(logConfig.level, logConfig.limit + 1, logConfig.sort === 'ascending' ? 'asc' : 'desc');
|
||||
const jsonLogs: LogInfoJson[] = slicedLog.map(x => ({...x, formattedMessage: x[MESSAGE]}));
|
||||
return res.json({data: jsonLogs, settings: logConfig});
|
||||
const slicedLog = getLogs(logger.levels.values[logConfig.level], logConfig.limit + 1, logConfig.sort === 'ascending' ? 'asc' : 'desc');
|
||||
return res.json({data: slicedLog, settings: logConfig});
|
||||
});
|
||||
|
||||
app.put('/api/logs', async (req, res) => {
|
||||
logConfig.level = req.body.level as LogLevel | undefined ?? logConfig.level;
|
||||
logConfig.limit = req.body.limit ?? logConfig.limit;
|
||||
const slicedLog = getLogs(logConfig.level, logConfig.limit + 1, logConfig.sort === 'ascending' ? 'asc' : 'desc');
|
||||
// @ts-ignore
|
||||
const slicedLog = getLogs(logger.levels.values[logConfig.level], logConfig.limit + 1, logConfig.sort === 'ascending' ? 'asc' : 'desc');
|
||||
// @ts-expect-error logLevel not part of session
|
||||
req.session.logLevel = logConfig.level;
|
||||
// @ts-ignore
|
||||
// @ts-expect-error limit not part of session
|
||||
req.session.limit = logConfig.limit;
|
||||
const jsonLogs: LogInfoJson[] = slicedLog.map(x => ({...x, formattedMessage: x[MESSAGE]}));
|
||||
return res.json({data: jsonLogs, settings: logConfig});
|
||||
return res.json({data: slicedLog, settings: logConfig});
|
||||
});
|
||||
|
||||
app.get('/api/events', async (req, res) => {
|
||||
@@ -201,7 +188,9 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
players: 'players' in x ? (x as MemorySource).playersToObject() : {}
|
||||
players: 'players' in x ? (x as MemorySource).playersToObject() : {},
|
||||
sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY,
|
||||
supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed
|
||||
};
|
||||
if(!x.isReady()) {
|
||||
if(x.buildOK === false) {
|
||||
@@ -228,7 +217,6 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
type,
|
||||
tracksScrobbled = 0,
|
||||
name,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
@@ -243,14 +231,20 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
initialized,
|
||||
initialized: x.isReady(),
|
||||
deadLetterScrobbles: x.deadLetterScrobbles.length,
|
||||
queued: x.queuedScrobbles.length
|
||||
};
|
||||
if (!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if (requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
if (!base.initialized) {
|
||||
if(x.buildOK === false) {
|
||||
base.status = 'Initializing Data Failed';
|
||||
} else if(x.connectionOK === false) {
|
||||
base.status = 'Communication Failed';
|
||||
} else if (requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else {
|
||||
base.status = 'Not Ready';
|
||||
}
|
||||
} else {
|
||||
base.status = scrobbling ? 'Running' : 'Idle';
|
||||
}
|
||||
@@ -263,11 +257,25 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
query: {
|
||||
upstream = 'false'
|
||||
}
|
||||
} = req;
|
||||
|
||||
let result: PlayObject[] = [];
|
||||
if (source !== undefined) {
|
||||
result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
|
||||
if (upstream === 'true' || upstream === '1') {
|
||||
if (!(source as AbstractSource).supportsUpstreamRecentlyPlayed) {
|
||||
return res.status(409).json({message: 'Fetching upstream recently played is not supported for this source'});
|
||||
}
|
||||
try {
|
||||
result = await (source as AbstractSource).getUpstreamRecentlyPlayed();
|
||||
} catch (e) {
|
||||
return res.status(500).json({message: e.message});
|
||||
}
|
||||
} else {
|
||||
result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
@@ -279,7 +287,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
scrobbleClient: client,
|
||||
} = req;
|
||||
|
||||
let result: DeadLetterScrobble<PlayObject>[] = (client as AbstractScrobbleClient).deadLetterScrobbles;
|
||||
const result: DeadLetterScrobble<PlayObject>[] = (client as AbstractScrobbleClient).deadLetterScrobbles;
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
@@ -290,9 +298,11 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
scrobbleClient: client,
|
||||
} = req;
|
||||
|
||||
(client as AbstractScrobbleClient).logger.verbose('User requested processing of all dead letter scrobbles via API');
|
||||
|
||||
await (client as AbstractScrobbleClient).processDeadLetterQueue(1000);
|
||||
|
||||
let result: DeadLetterScrobble<PlayObject>[] = (client as AbstractScrobbleClient).deadLetterScrobbles;
|
||||
const result: DeadLetterScrobble<PlayObject>[] = (client as AbstractScrobbleClient).deadLetterScrobbles;
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
@@ -308,9 +318,12 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
|
||||
const deadId = id as string;
|
||||
|
||||
(client as AbstractScrobbleClient).logger.verbose(`User requested processing of dead letter scrobble ${deadId} via API call`)
|
||||
|
||||
const deadScrobble = (client as AbstractScrobbleClient).deadLetterScrobbles.find(x => x.id === deadId);
|
||||
|
||||
if(deadScrobble === undefined) {
|
||||
(client as AbstractScrobbleClient).logger.debug(`No dead letter scrobble with ID ${deadId}`)
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
@@ -329,6 +342,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
scrobbleClient: client,
|
||||
} = req;
|
||||
|
||||
(client as AbstractScrobbleClient).logger.verbose('User requested deletion of all dead letter scrobbles via API');
|
||||
|
||||
(client as AbstractScrobbleClient).removeDeadLetterScrobbles();
|
||||
|
||||
return res.json([]);
|
||||
@@ -345,9 +360,12 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
|
||||
const deadId = id as string;
|
||||
|
||||
(client as AbstractScrobbleClient).logger.verbose(`User requested removal of dead letter scrobble ${deadId} via API call`)
|
||||
|
||||
const deadScrobble = (client as AbstractScrobbleClient).deadLetterScrobbles.find(x => x.id === deadId);
|
||||
|
||||
if(deadScrobble === undefined) {
|
||||
(client as AbstractScrobbleClient).logger.verbose(`No dead letter scrobble with ID ${deadId}`)
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
@@ -357,7 +375,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
|
||||
app.getAsync('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error scrobbleClient not part of req
|
||||
scrobbleClient: client,
|
||||
} = req;
|
||||
|
||||
@@ -370,11 +388,13 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
});
|
||||
|
||||
app.use('/api/poll', sourceRequiredMiddle);
|
||||
app.getAsync('/api/poll', async function (req, res) {
|
||||
app.getAsync('/api/poll', async (req, res) => {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
const source = req.scrobbleSource as AbstractSource;
|
||||
source.logger.verbose('User requested (re)start via API call');
|
||||
|
||||
if (!source.canPoll) {
|
||||
source.logger.warn(`Does not support polling (${source.type})`);
|
||||
return res.status(400).send(`Specified source cannot poll (${source.type})`);
|
||||
}
|
||||
|
||||
@@ -391,24 +411,23 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
});
|
||||
|
||||
app.use('/api/client/init', clientRequiredMiddle);
|
||||
app.postAsync('/api/client/init', async function (req, res) {
|
||||
app.postAsync('/api/client/init', async (req, res) => {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
const client = req.scrobbleClient as AbstractScrobbleClient;
|
||||
client.logger.verbose('User requested (re)start via API call');
|
||||
|
||||
logger.info('Checking (and trying) to stop scrobbler if already running...');
|
||||
client.logger.info('Checking (and trying) to stop scrobbler if already running...');
|
||||
if(false === (await client.tryStopScrobbling())) {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
logger.info('Trying to start scrobbler...');
|
||||
client.logger.info('Trying to start scrobbler...');
|
||||
await client.initScrobbleMonitoring();
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
app.getAsync('/health', async function(req, res) {
|
||||
return res.redirect(307, `/api/${req.url.slice(1)}`);
|
||||
});
|
||||
app.getAsync('/api/health', async function (req, res) {
|
||||
app.getAsync('/health', async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`));
|
||||
app.getAsync('/api/health', async (req, res) => {
|
||||
const {
|
||||
type,
|
||||
name
|
||||
@@ -421,11 +440,15 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
return res.status((clientsReady && sourcesReady) ? 200 : 500).json({messages: sourceMessages.concat(clientMessages)});
|
||||
});
|
||||
|
||||
app.useAsync('/api/*', async function (req, res) {
|
||||
app.getAsync('/api/version', async (req, res) => {
|
||||
return res.json({version: root.get('version')});
|
||||
});
|
||||
|
||||
app.useAsync('/api/*', async (req, res) => {
|
||||
const remote = req.connection.remoteAddress;
|
||||
const proxyRemote = req.headers["x-forwarded-for"];
|
||||
const ua = req.headers["user-agent"];
|
||||
logger.debug(`Server received ${req.method} request from ${remote}${proxyRemote !== undefined ? ` (${proxyRemote})` : ''}${ua !== undefined ? ` (UA: ${ua})` : ''} to unknown route: ${req.url}`);
|
||||
logger.debug(`Server received ${req.method} request from ${remote}${proxyRemote !== undefined ? ` (${proxyRemote})` : ''}${ua !== undefined ? ` (UA: ${ua})` : ''} to unknown route: ${req.originalUrl}`);
|
||||
return res.sendStatus(404);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import {ExpressWithAsync} from "@awaitjs/express";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import passport from "passport";
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic.js";
|
||||
import ScrobbleClients from "../scrobblers/ScrobbleClients.js";
|
||||
import LastfmScrobbler from "../scrobblers/LastfmScrobbler.js";
|
||||
import ScrobbleClients from "../scrobblers/ScrobbleClients.js";
|
||||
import LastfmSource from "../sources/LastfmSource.js";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import SpotifySource from "../sources/SpotifySource.js";
|
||||
|
||||
export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
app.use('/api/client/auth', clientMiddle);
|
||||
app.getAsync('/api/client/auth', async function (req, res) {
|
||||
app.getAsync('/api/client/auth', async (req, res) => {
|
||||
const {
|
||||
scrobbleClient,
|
||||
} = req as any;
|
||||
@@ -25,7 +25,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
|
||||
});
|
||||
|
||||
app.use('/api/source/auth', sourceMiddle);
|
||||
app.getAsync('/api/source/auth', async function (req, res, next) {
|
||||
app.getAsync('/api/source/auth', async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
@@ -54,7 +54,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync(/.*callback$/, async function (req, res, next) {
|
||||
app.getAsync(/.*callback$/, async (req, res, next) => {
|
||||
if(req.url.indexOf('/api') !== 0) {
|
||||
return res.redirect(307, `/api${req.url}`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic.js";
|
||||
import { mergeArr, parseBool, sleep } from "../utils.js";
|
||||
import {ExpressWithAsync} from "@awaitjs/express";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import PlexSource, { plexRequestMiddle } from "../sources/PlexSource.js";
|
||||
import { PlexNotifier } from "../sources/ingressNotifiers/PlexNotifier.js";
|
||||
import DeezerSource from "../sources/DeezerSource.js";
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import passport from "passport";
|
||||
import DeezerSource from "../sources/DeezerSource.js";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { sleep } from "../utils.js";
|
||||
|
||||
export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
@@ -18,7 +15,7 @@ export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbl
|
||||
|
||||
// something about the deezer passport strategy makes express continue with the response even though it should wait for accesstoken callback and userprofile fetching
|
||||
// so to get around this add an additional middleware that loops/sleeps until we should have fetched everything ¯\_(ツ)_/¯
|
||||
app.getAsync(/.*deezer\/callback*$/, function (req, res, next) {
|
||||
app.getAsync(/.*deezer\/callback*$/, (req, res, next) => {
|
||||
if(req.url.indexOf('/api') !== 0) {
|
||||
return res.redirect(307, `/api${req.url}`);
|
||||
}
|
||||
@@ -26,9 +23,9 @@ export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbl
|
||||
const entity = scrobbleSources.getByName(req.session.deezerSource as string);
|
||||
const passportFunc = passport.authenticate(`deezer-${entity.name}`, {session: false});
|
||||
return passportFunc(req, res, next);
|
||||
}, async function (req, res) {
|
||||
}, async (req, res) => {
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
let entity = scrobbleSources.getByName(req.session.deezerSource as string) as DeezerSource;
|
||||
const entity = scrobbleSources.getByName(req.session.deezerSource as string) as DeezerSource;
|
||||
for(let i = 0; i < 3; i++) {
|
||||
if(entity.error !== undefined) {
|
||||
return res.send('Error with deezer credentials storage');
|
||||
|
||||
+26
-19
@@ -1,26 +1,22 @@
|
||||
import {addAsync, Router} from '@awaitjs/express';
|
||||
import express from 'express';
|
||||
import ViteExpress from "vite-express";
|
||||
import { addAsync, Router } from '@awaitjs/express';
|
||||
import { childLogger, LogDataPretty, Logger } from "@foxxmd/logging";
|
||||
import bodyParser from 'body-parser';
|
||||
import passport from 'passport';
|
||||
import { stripIndents } from "common-tags";
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import path from "path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import passport from 'passport';
|
||||
import ViteExpress from "vite-express";
|
||||
import { getRoot } from "../ioc.js";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import { LogInfo } from "../../core/Atomic.js";
|
||||
import { getAddress, parseBool } from "../utils.js";
|
||||
import { setupApi } from "./api.js";
|
||||
import { getAddress, mergeArr, parseBool } from "../utils.js";
|
||||
import {stripIndents} from "common-tags";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
//const buildDir = path.join(process.cwd() + "/build");
|
||||
|
||||
const app = addAsync(express());
|
||||
const router = Router();
|
||||
|
||||
export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[] = []) => {
|
||||
export const initServer = async (parentLogger: Logger, appLoggerStream: PassThrough, initialOutput: LogDataPretty[] = []) => {
|
||||
|
||||
const logger = parentLogger.child({labels: ['API']}, mergeArr);
|
||||
const logger = childLogger(parentLogger, 'API'); // parentLogger.child({labels: ['API']}, mergeArr);
|
||||
|
||||
try {
|
||||
app.use(router);
|
||||
@@ -39,12 +35,17 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
|
||||
|
||||
const root = getRoot();
|
||||
|
||||
if(root.get('disableWeb')) {
|
||||
logger.warn('API and Dashboard have been DISABLED. Note that any ingress sources (Plex, Jellyfin, Tautulli, etc...) will be unusable');
|
||||
return;
|
||||
}
|
||||
|
||||
const isProd = root.get('isProd');
|
||||
const port = root.get('port');
|
||||
const local = root.get('localUrl');
|
||||
const localDefined = root.get('hasDefinedBaseUrl');
|
||||
|
||||
setupApi(app, logger, initialOutput);
|
||||
setupApi(app, logger, appLoggerStream, initialOutput);
|
||||
|
||||
const addy = getAddress();
|
||||
const addresses: string[] = [];
|
||||
@@ -74,7 +75,13 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
|
||||
if(process.env.USE_HASH_ROUTER === undefined) {
|
||||
process.env.USE_HASH_ROUTER = root.get('isSubPath');
|
||||
}
|
||||
ViteExpress.config({mode: isProd ? 'production' : 'development'});
|
||||
|
||||
ViteExpress.config({
|
||||
mode: isProd ? 'production' : 'development',
|
||||
inlineViteConfig: {
|
||||
base: localDefined && local.pathname !== '/' ? local.toString() : '/'
|
||||
}
|
||||
});
|
||||
try {
|
||||
ViteExpress.listen(app, port, () => {
|
||||
const start = stripIndents`\n
|
||||
@@ -87,13 +94,13 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
|
||||
logger.info(`User-defined base URL for UI and redirect URLs (spotify, deezer, lastfm): ${local}`)
|
||||
}
|
||||
}).on('error', (err) => {
|
||||
throw new ErrorWithCause('Server encountered unrecoverable error', {cause: err});
|
||||
throw new Error('Server encountered unrecoverable error', {cause: err});
|
||||
});
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Server encountered unrecoverable error', {cause: e});
|
||||
throw new Error('Server encountered unrecoverable error', {cause: e});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Server crashed with uncaught exception', {cause: e});
|
||||
throw new Error('Server crashed with uncaught exception', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { parseBool, remoteHostIdentifiers } from "../utils.js";
|
||||
import {ExpressWithAsync} from "@awaitjs/express";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import bodyParser from "body-parser";
|
||||
import { JellyfinNotifier } from "../sources/ingressNotifiers/JellyfinNotifier.js";
|
||||
import JellyfinSource from "../sources/JellyfinSource.js";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { parseBool, remoteHostIdentifiers } from "../utils.js";
|
||||
|
||||
export const setupJellyfinRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
@@ -17,18 +17,18 @@ export const setupJellyfinRoutes = (app: ExpressWithAsync, logger: Logger, scrob
|
||||
// req.rawBody = buf.toString();
|
||||
// }
|
||||
});
|
||||
const jellyIngress = new JellyfinNotifier();
|
||||
app.postAsync('/jellyfin', async function(req, res) {
|
||||
const jellyIngress = new JellyfinNotifier(logger);
|
||||
app.postAsync('/jellyfin', async (req, res) => {
|
||||
res.redirect(307, '/api/jellyfin/ingress');
|
||||
});
|
||||
app.postAsync('/api/jellyfin/ingress',
|
||||
async function (req, res, next) {
|
||||
async (req, res, next) => {
|
||||
// track request before parsing body to ensure we at least log that something is happening
|
||||
// (in the event body parsing does not work or request is not POST/PATCH)
|
||||
jellyIngress.trackIngress(req, true);
|
||||
next();
|
||||
},
|
||||
jellyfinJsonParser, async function (req, res) {
|
||||
jellyfinJsonParser, async (req, res) => {
|
||||
jellyIngress.trackIngress(req, false);
|
||||
|
||||
res.send('OK');
|
||||
@@ -59,8 +59,6 @@ export const setupJellyfinRoutes = (app: ExpressWithAsync, logger: Logger, scrob
|
||||
}
|
||||
const logPayload = pSources.some(x => {
|
||||
const {
|
||||
data: {
|
||||
} = {},
|
||||
options: {
|
||||
logPayload = parseBool(process.env.DEBUG_MODE)
|
||||
} = {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic.js";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
|
||||
export const makeSourceCheckMiddle = (sources: any) => (required: boolean ): ExpressHandler => (req: any, res: any, next: any) => {
|
||||
const {
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic.js";
|
||||
import { mergeArr, parseBool } from "../utils.js";
|
||||
import {ExpressWithAsync} from "@awaitjs/express";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import PlexSource, { plexRequestMiddle } from "../sources/PlexSource.js";
|
||||
import { PlexNotifier } from "../sources/ingressNotifiers/PlexNotifier.js";
|
||||
import PlexSource, { plexRequestMiddle } from "../sources/PlexSource.js";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
|
||||
export const setupPlexRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
const plexMiddle = plexRequestMiddle();
|
||||
const plexLog = logger.child({labels: ['Plex Request']}, mergeArr);
|
||||
const plexIngress = new PlexNotifier();
|
||||
const plexIngressMiddle: ExpressHandler = async function (req, res, next) {
|
||||
const plexMiddle = plexRequestMiddle(logger);
|
||||
const plexLog = childLogger(logger, 'Plex Request');
|
||||
const plexIngress = new PlexNotifier(logger);
|
||||
const plexIngressMiddle: ExpressHandler = async (req, res, next) => {
|
||||
// track request before parsing body to ensure we at least log that something is happening
|
||||
// (in the event body parsing does not work or request is not POST/PATCH)
|
||||
plexIngress.trackIngress(req, true);
|
||||
next();
|
||||
};
|
||||
const plexIngressRoute: ExpressHandler = async function (req, res) {
|
||||
const plexIngressRoute: ExpressHandler = async (req, res) => {
|
||||
plexIngress.trackIngress(req, false);
|
||||
|
||||
const {payload} = req as any;
|
||||
@@ -36,7 +35,7 @@ export const setupPlexRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleS
|
||||
|
||||
res.send('OK');
|
||||
};
|
||||
app.postAsync('/plex', async function (req, res) {
|
||||
app.postAsync('/plex', async (req, res) => {
|
||||
res.redirect(307, '/api/plex/ingress');
|
||||
});
|
||||
app.postAsync('/api/plex/ingress', plexIngressMiddle, plexMiddle, plexIngressRoute);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { TautulliNotifier } from "../sources/ingressNotifiers/TautulliNotifier.js";
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic.js";
|
||||
import { TautulliNotifier } from "../sources/ingressNotifiers/TautulliNotifier.js";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import TautulliSource from "../sources/TautulliSource.js";
|
||||
import { parseBool } from "../utils.js";
|
||||
import {ExpressWithAsync} from "@awaitjs/express";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
|
||||
export const setupTautulliRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
const tauIngress = new TautulliNotifier();
|
||||
const tauIngress = new TautulliNotifier(logger);
|
||||
const tautulliIngressRoute: ExpressHandler = async function(this: any, req, res) {
|
||||
tauIngress.trackIngress(req, false);
|
||||
|
||||
@@ -43,7 +43,7 @@ export const setupTautulliRoutes = (app: ExpressWithAsync, logger: Logger, scrob
|
||||
res.send('OK');
|
||||
};
|
||||
|
||||
app.postAsync('/tautulli', async function(req, res) {
|
||||
app.postAsync('/tautulli', async (req, res) => {
|
||||
res.redirect(307, '/api/tautulli/ingress');
|
||||
});
|
||||
app.postAsync('/api/tautulli/ingress', tautulliIngressRoute);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { mergeArr, parseBool, remoteHostIdentifiers } from "../utils.js";
|
||||
import {ExpressWithAsync} from "@awaitjs/express";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import bodyParser from "body-parser";
|
||||
import { WebScrobblerPayload } from "../common/vendor/webscrobbler/interfaces.js";
|
||||
import { WebhookNotifier } from "../sources/ingressNotifiers/WebhookNotifier.js";
|
||||
import { nonEmptyBody } from "./middleware.js";
|
||||
import { WebScrobblerSource } from "../sources/WebScrobblerSource.js";
|
||||
import cors from 'cors';
|
||||
import path from "path";
|
||||
import { WebhookNotifier } from "../sources/ingressNotifiers/WebhookNotifier.js";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { WebScrobblerSource } from "../sources/WebScrobblerSource.js";
|
||||
import { nonEmptyBody } from "./middleware.js";
|
||||
|
||||
const corsOpts: cors.CorsOptions = {
|
||||
methods: ['POST']
|
||||
}
|
||||
|
||||
export const setupWebscrobblerRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
const logger = parentLogger.child({labels: ['Ingress', 'WebScrobbler']}, mergeArr);
|
||||
const logger = childLogger(parentLogger, ['Ingress', 'WebScrobbler']);
|
||||
|
||||
const webScrobblerJsonParser = bodyParser.json({
|
||||
type: ['text/*', 'application/json'],
|
||||
@@ -21,15 +24,20 @@ export const setupWebscrobblerRoutes = (app: ExpressWithAsync, parentLogger: Log
|
||||
// req.rawBody = buf.toString();
|
||||
// }
|
||||
});
|
||||
const webhookIngress = new WebhookNotifier();
|
||||
app.postAsync('/api/webscrobbler',
|
||||
async function (req, res, next) {
|
||||
// track request before parsing body to ensure we at least log that something is happening
|
||||
// (in the event body parsing does not work or request is not POST/PATCH)
|
||||
const webhookIngress = new WebhookNotifier(logger);
|
||||
app.options('/api/webscrobbler*', async (req, res, next) => {
|
||||
webhookIngress.trackIngress(req, true);
|
||||
next();
|
||||
},
|
||||
cors(corsOpts));
|
||||
|
||||
app.postAsync('/api/webscrobbler*',
|
||||
async (req, res, next) => {
|
||||
webhookIngress.trackIngress(req, true);
|
||||
next();
|
||||
},
|
||||
webScrobblerJsonParser, nonEmptyBody(logger, 'WebScrobbler Extension'), async function (req, res) {
|
||||
cors(corsOpts),
|
||||
webScrobblerJsonParser, nonEmptyBody(logger, 'WebScrobbler Extension'), async (req, res) => {
|
||||
webhookIngress.trackIngress(req, false);
|
||||
|
||||
res.sendStatus(200);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user