mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2cd7e2991 | ||
|
|
92d8eb8d6f | ||
|
|
d14e7d9b2e | ||
|
|
e4befae55a | ||
|
|
0e00b425b4 | ||
|
|
fcf6c8eb4c | ||
|
|
242bacc1be | ||
|
|
cbe28f97a0 | ||
|
|
336972f586 | ||
|
|
81edb31e7b | ||
|
|
dbde66ad77 | ||
|
|
efbf2da816 | ||
|
|
38d1db2f3f | ||
|
|
265ae82663 | ||
|
|
2bd2972af6 | ||
|
|
1bdf083e28 | ||
|
|
4c233fdded | ||
|
|
e0060fe3e5 | ||
|
|
2adb3bd052 | ||
|
|
0dca49ab43 | ||
|
|
6a71d4a495 | ||
|
|
230ddf4a48 | ||
|
|
e4e1797ac5 | ||
|
|
1dd3185715 | ||
|
|
4fee231a7b | ||
|
|
b5ead39792 | ||
|
|
190431b9d4 | ||
|
|
210fa35cb6 | ||
|
|
479af1cd5e | ||
|
|
c72831cd12 | ||
|
|
f68a9493ce | ||
|
|
78a93d0809 | ||
|
|
3680ac8af1 | ||
|
|
e74ba52c47 | ||
|
|
d3cd07c5c3 | ||
|
|
2b890d4704 | ||
|
|
47eead2a18 | ||
|
|
38ea3115f6 | ||
|
|
f11a2b5442 | ||
|
|
818f67d6da | ||
|
|
84f353220d | ||
|
|
47541a449b | ||
|
|
1a62edab37 | ||
|
|
5927450840 | ||
|
|
ec9bea70fd | ||
|
|
2f9a45b1e4 | ||
|
|
3c649df2e9 | ||
|
|
ab1766ca0a | ||
|
|
a42de78ad3 | ||
|
|
81bde3a771 | ||
|
|
982e6655ef | ||
|
|
44ce0cba3c | ||
|
|
c1e1c69b5e | ||
|
|
142defdd20 | ||
|
|
aad9f2b4de | ||
|
|
8e92407182 | ||
|
|
94210e29d1 | ||
|
|
d2c6194314 | ||
|
|
11ca7a724d | ||
|
|
3057908074 | ||
|
|
6a9e538845 | ||
|
|
130538cd9a | ||
|
|
1486613744 | ||
|
|
f2516206c0 | ||
|
|
f8902e05a6 | ||
|
|
c59b47c801 | ||
|
|
0e4845f2e2 | ||
|
|
e2775cd9de | ||
|
|
816bec88c8 | ||
|
|
d724bef4e4 |
@@ -50,9 +50,17 @@ body:
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode)
|
||||
Please copy and paste any/all relevant from the DEBUG level log output.
|
||||
Even if your issue does not seem to be shown in the logs you should still add as much detail as possible, a _lack_ of issues in logs may be a good hint during debugging.
|
||||
Your logs will be automatically formatted into code, do not need use backticks.
|
||||
If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode).
|
||||
render: shell
|
||||
placeholder: |
|
||||
[2025-02-14 12:47:57.170 -0500] VERBOSE: [Init] Config Dir ENV: undefined -> Resolved: CWD/config
|
||||
[2025-02-14 12:47:57.236 -0500] INFO : [Init] Debug Mode: NO
|
||||
[2025-02-14 12:47:57.258 -0500] INFO : [Init] Version: master
|
||||
[2025-02-14 12:47:57.259 -0500] INFO : [Init] Generating schema definitions...
|
||||
[2025-02-14 12:47:59.148 -0500] DEBUG : [App] [Scrobblers] [Maloja - default] Found 100 recent scrobbles
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
|
||||
@@ -37,10 +37,10 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: appvars
|
||||
@@ -64,17 +64,17 @@ jobs:
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_SLUG }}
|
||||
${{ env.GHCR_SLUG }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
# - name: Extract metadata (tags, labels) for Docker
|
||||
# id: meta
|
||||
# uses: docker/metadata-action@v5
|
||||
# with:
|
||||
# images: |
|
||||
# ${{ env.DOCKERHUB_SLUG }}
|
||||
# ${{ env.GHCR_SLUG }}
|
||||
# labels: |
|
||||
# org.opencontainers.image.title=Multi-Scrobbler
|
||||
# org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
# org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -138,6 +138,19 @@ jobs:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -155,7 +168,7 @@ jobs:
|
||||
type=edge
|
||||
|
||||
# maybe re-enable branch-named tags in the futures
|
||||
#type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
|
||||
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
|
||||
type=match,pattern=\d.\d.\d$,priority=901
|
||||
@@ -169,27 +182,17 @@ jobs:
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("${{ env.DOCKERHUB_SLUG }}")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("${{ env.GHCR_SLUG }}")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.GHCR_SLUG }}@sha256:%s ' *)
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
# - name: Inspect image
|
||||
# run: |
|
||||
# docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ env.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
+159
-47
@@ -1,5 +1,9 @@
|
||||
name: PR Workflow
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
@@ -17,11 +21,11 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
release-snapshot:
|
||||
name: Release snapshot
|
||||
runs-on: ubuntu-latest
|
||||
build-snapshot:
|
||||
name: Build OCI snapshot
|
||||
needs: test
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
@@ -29,64 +33,172 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: ./Dockerfile
|
||||
suffix: ''
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
platform: linux/amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
platform: linux/arm64
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# list all registries to push to and join all non-empty with comma
|
||||
# https://unix.stackexchange.com/a/693165/116849
|
||||
# https://stackoverflow.com/a/9429887/1469797
|
||||
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
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 }}
|
||||
# do not use here!
|
||||
# instead we can specify repo#sha in build-push-action so we only deal with foreign SHA for actually building image
|
||||
# - uses: actions/checkout@v4
|
||||
# with:
|
||||
# ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set short git commit SHA
|
||||
run: |
|
||||
short=$(echo "${{ github.event.pull_request.head.sha }}" | head -c7)
|
||||
echo "COMMIT_SHORT_SHA=$short" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
- name: Get App Version
|
||||
id: appversion
|
||||
env:
|
||||
APP_VERSION: ${{ format('pr{0}-{1}', github.event.number, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
context: .
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
# specify foreign repo#sha from PR that is used to build image
|
||||
context: ${{github.event.pull_request.head.repo.clone_url}}#${{github.event.pull_request.head.sha}}
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{env.APP_VERSION}}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: ${{ !env.ACT}}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
|
||||
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release-snapshot:
|
||||
name: Merge OCI Images and Push
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs:
|
||||
- build-snapshot
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
${{ vars.GHCR_SLUG }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
flavor: |
|
||||
latest=false
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Create manifest list and push dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push gchr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Inspect image ghcr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
|
||||
combine-and-comment:
|
||||
name: Leave comment
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Based on https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
|
||||
|
||||
name: Publish Docker image to Dockerhub
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -18,26 +24,54 @@ on:
|
||||
# release:
|
||||
# types: [ published ]
|
||||
|
||||
# define in GH Repository -> Actions -> Variables (or act .variables) to enable pushing to registries
|
||||
# -- will only push to registries that are defined
|
||||
# EX
|
||||
# DOCKERHUB_SLUG=foxxmd/multi-scrobbler
|
||||
# GHCR_SLUG=ghcr.io/foxxmd/multi-scrobbler
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
|
||||
push_to_registry:
|
||||
name: Build and push container images
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build OCI Images
|
||||
if: ${{ github.event_name != 'pull_request' && (vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '') }}
|
||||
needs: test
|
||||
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
platform: linux/amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
platform: linux/arm64
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
# list all registries to push to and join all non-empty with comma
|
||||
# https://unix.stackexchange.com/a/693165/116849
|
||||
# https://stackoverflow.com/a/9429887/1469797
|
||||
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: vars
|
||||
id: appvars
|
||||
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
|
||||
# short sha available under env.COMMIT_SHORT_SHA
|
||||
run: |
|
||||
@@ -46,33 +80,116 @@ jobs:
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
- name: Get App Version
|
||||
id: appversion
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
|
||||
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge OCI Images and Push
|
||||
if: ${{ github.event_name != 'pull_request' && (vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs:
|
||||
- build
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
foxxmd/multi-scrobbler
|
||||
ghcr.io/foxxmd/multi-scrobbler
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
${{ vars.GHCR_SLUG }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=edge
|
||||
|
||||
# maybe re-enable branch-named tags in the futures
|
||||
#type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
# push with branch name as tag if not master/main
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
|
||||
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
|
||||
type=match,pattern=\d.\d.\d$,priority=901
|
||||
@@ -81,28 +198,31 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
# flavor: |
|
||||
# latest=false
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Create manifest list and push dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push gchr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# https://github.com/docker/build-push-action/issues/1026#issue-2041857786
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{env.APP_VERSION}}
|
||||
push: ${{ !env.ACT }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Inspect image ghcr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
node-version:
|
||||
description: "Node version"
|
||||
required: false
|
||||
default: '18.x'
|
||||
default: '20.x'
|
||||
type: string
|
||||
ref:
|
||||
description: "Ref to checkout"
|
||||
@@ -28,15 +28,17 @@ jobs:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install concurrently
|
||||
run: npm install -g concurrently
|
||||
|
||||
- name: Install dev dependencies
|
||||
run: npm ci
|
||||
run: npm run install:parallel
|
||||
|
||||
- name: Test Backend
|
||||
run: npm run test
|
||||
|
||||
- name: Install Docs Deps
|
||||
run: NODE_ENV=production npm run docs:install
|
||||
- name: Build
|
||||
run: NODE_ENV=production npm run build
|
||||
run: npm run build:parallel
|
||||
|
||||
# remove modules that might include dev stuff
|
||||
# so that in the next step we are sure that prod-only runs work correctly
|
||||
|
||||
+9
-7
@@ -1,7 +1,7 @@
|
||||
FROM ghcr.io/linuxserver/baseimage-debian:bookworm AS base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
ENV NODE_VERSION 20.11.1
|
||||
ENV NODE_VERSION=20.18.1
|
||||
|
||||
# borrowing openssl header removal trick from offical docker-node
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L8
|
||||
@@ -49,6 +49,8 @@ RUN \
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
RUN npm install -g concurrently
|
||||
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
@@ -61,6 +63,7 @@ FROM base AS build
|
||||
|
||||
COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --chown=abc:abc docsite/package*.json tsconfig.json ./docsite/
|
||||
|
||||
# for debugging, so the build fails faster when timing out (arm64)
|
||||
#RUN npm config set fetch-retries 1 && \
|
||||
@@ -74,17 +77,16 @@ COPY --chown=abc:abc patches ./patches
|
||||
|
||||
# This FAILED for node < 20 when building arm64 but not amd64 (and alpine-based Dockerfile has no issues building arm64)
|
||||
# see https://github.com/FoxxMD/multi-scrobbler/issues/126
|
||||
RUN npm ci \
|
||||
--verbose \
|
||||
# --no-audit \
|
||||
&& chown -R root:root node_modules
|
||||
RUN npm run install:parallel \
|
||||
&& chown -R root:root node_modules \
|
||||
&& chown -R root:root docsite/node_modules
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
# need to set before build so server/client build is optimized and has constants (if needed)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run docs:install && npm run schema && npm run build && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
RUN npm run build:parallel && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
|
||||
FROM base AS app
|
||||
|
||||
@@ -103,7 +105,7 @@ ENV IS_DOCKER=true
|
||||
ARG APP_BUILD_VERSION
|
||||
ENV APP_VERSION=$APP_BUILD_VERSION
|
||||
|
||||
RUN npm ci --omit=dev \
|
||||
RUN npm ci --omit=dev --no-audit \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf /root/.cache
|
||||
|
||||
@@ -19,7 +19,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
|
||||
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
|
||||
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
|
||||
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
|
||||
@@ -29,6 +31,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
|
||||
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
|
||||
* [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc)
|
||||
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
|
||||
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
|
||||
* [Icecast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#icecast)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"type": "azuracast",
|
||||
"enable": true,
|
||||
"name": "azura",
|
||||
"data": {
|
||||
"url": "ws://192.168.0.101",
|
||||
"station": "my-station-name",
|
||||
"monitorWhenLive": true,
|
||||
"monitorWhenListeners": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"name": "myLfm",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"slug": "mySlug"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"name": "myLz",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"token": "myToken"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "myIcy",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://myIcecast.com/radio"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"name": "myYamaha",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "192.168.0.101"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
};
|
||||
@@ -10,17 +10,22 @@ import SchemaLink from "../../src/components/SchemaLink";
|
||||
import AIOExample from "../../src/components/AIOExample";
|
||||
|
||||
import AIOConfig from '!!raw-loader!../../../config/config.json.example';
|
||||
import AzuracastConfig from '!!raw-loader!../../../config/azuracast.json.example';
|
||||
import ChromecastConfig from '!!raw-loader!../../../config/chromecast.json.example';
|
||||
import DeezerConfig from '!!raw-loader!../../../config/chromecast.json.example';
|
||||
import IcecastConfig from '!!raw-loader!../../../config/icecast.json.example';
|
||||
import JellyfinConfig from '!!raw-loader!../../../config/jellyfin.json.example';
|
||||
import JriverfinConfig from '!!raw-loader!../../../config/jriver.json.example';
|
||||
import KodiConfig from '!!raw-loader!../../../config/kodi.json.example';
|
||||
import LastfmConfig from '!!raw-loader!../../../config/lastfm.json.example';
|
||||
import LastfmEndpointConfig from '!!raw-loader!../../../config/endpointlfm.json.example';
|
||||
import ListenbrainzConfig from '!!raw-loader!../../../config/listenbrainz.json.example';
|
||||
import ListenbrainzEndpointConfig from '!!raw-loader!../../../config/endpointlz.json.example';
|
||||
import MalojaConfig from '!!raw-loader!../../../config/maloja.json.example';
|
||||
import MopidyConfig from '!!raw-loader!../../../config/mopidy.json.example';
|
||||
import MprisConfig from '!!raw-loader!../../../config/mpris.json.example';
|
||||
import MusikcubeConfig from '!!raw-loader!../../../config/musikcube.json.example';
|
||||
import MusiccastConfig from '!!raw-loader!../../../config/musiccast.json.example';
|
||||
import MPDConfig from '!!raw-loader!../../../config/mpd.json.example';
|
||||
import PlexConfig from '!!raw-loader!../../../config/plex.json.example';
|
||||
import PlexWebhookConfig from '!!raw-loader!../../../config/plex.webhook.json.example';
|
||||
@@ -214,6 +219,67 @@ Disable using either:
|
||||
* ENV `DISABLE_WEB=true`
|
||||
* In [All-in-One File](./?configType=aio#configuration-types) use the top-level property `"disableWeb": true`
|
||||
|
||||
## Common Source Functionality
|
||||
|
||||
Some options and functionality is shared between all or some Sources. Detailed below is information on what these features are and how to use/configured them.
|
||||
|
||||
#### Scrobble Thresholds
|
||||
|
||||
For Sources where multi-scrobbler monitors active listening, it uses one of two metrics to determine if a Song is scrobbable based on your listening:
|
||||
|
||||
* **percent** of track listened to (50% or more)
|
||||
* or amount of time (**duration**) track was listened to (4 minutes or more)
|
||||
|
||||
These default values are based on [last.fm's scrobble guidance.](https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble)
|
||||
|
||||
Each Source can have these values customized using values in the Source's `options` in [file or aio config](./?configType=file#configuration-types):
|
||||
|
||||
```json5 title="azuracast.json"
|
||||
{
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"scrobbleThresholds": {
|
||||
"duration": 40, // scrobbable if listened to for 40 seconds or more
|
||||
"percent": 20 // scrobbable if listened to for 20% or more of the track's length
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These can also be configured **globally** for all Sources using ENVs:
|
||||
|
||||
```
|
||||
SOURCE_SCROBBLE_DURATION=40
|
||||
SOURCE_SCROBBLE_PERCENT=20
|
||||
```
|
||||
|
||||
#### Should Scrobble Behavior
|
||||
|
||||
:::note
|
||||
|
||||
This feature is currently available only for some Sources. Check each Source's documentation to see if it supported.
|
||||
|
||||
:::
|
||||
|
||||
For _most_ Sources multi-scrobbler can determine if a user is listening and scrobble automatically. For Sources which multi-scrobbler cannot do this or require additional configuration, a user can indicate to MS that it should or should not scrobble. This action can be taken on the Web Dashboard by clicking the link next to **Should Scrobble** found in each Source's status card.
|
||||
|
||||
Additionally, for these Sources, the default behavior for Scrobbling can be explicitly set by adding the following value to a Source's `options` in [file or aio config](./?configType=file#configuration-types):
|
||||
|
||||
```json5 title="azuracast.json"
|
||||
{
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
// set to true to always scrobble automatically
|
||||
// set to false to never scrobble automatically
|
||||
"systemScrobble": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Source Configurations
|
||||
|
||||
### [Spotify](https://www.spotify.com)
|
||||
@@ -638,8 +704,67 @@ See the [Last.fm (Client)](#lastfm) setup for registration instructions.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Last.fm (Endpoint)](https://www.last.fm)
|
||||
|
||||
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Last.fm server.
|
||||
|
||||
##### URL
|
||||
|
||||
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Last.fm scrobbles at
|
||||
|
||||
```
|
||||
http://localhost:9078/2.0/
|
||||
```
|
||||
|
||||
which is the "standard" Last.fm server path for scrobbling.
|
||||
|
||||
Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens.
|
||||
|
||||
If a slug is used then the URL will be:
|
||||
|
||||
```
|
||||
http://localhost:9078/api/lastfm/mySlug
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `LFMENDPOINT_ENABLE` | No | | Use LFM Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
|
||||
| `LFM_SLUG` | No | | (Optional) The URL suffix to use for accepting LFM scrobbles |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/endpointlfm.json" language="json5">{LastfmEndpointConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="LastFMEndpointSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={LastfmEndpointConfig} name="endpointlfm"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="LastFMEndpointSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Listenbrainz (Source)](https://listenbrainz.org)
|
||||
|
||||
This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients.
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
@@ -679,6 +804,73 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Listenbrainz (Endpoint)](https://listenbrainz.org)
|
||||
|
||||
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Listenbrainz server. An example of this would be configuring [pano-scrobbler](https://github.com/kawaiiDango/pano-scrobbler) with a custom Listenbrainz URL to point to multi-scrobbler.
|
||||
|
||||
##### URL
|
||||
|
||||
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Listenbrainz scrobbles at
|
||||
|
||||
```
|
||||
http://localhost:9078/1/submit-listens
|
||||
```
|
||||
|
||||
which is the "standard" Listenbrainz server path for scrobbling.
|
||||
|
||||
Use a slug only if you need to setup multiple Listenbrainz Endpoint sources and cannot use different tokens.
|
||||
|
||||
If a slug is used then the URL will be:
|
||||
|
||||
```
|
||||
http://localhost:9078/api/listenbrainz/mySlug
|
||||
```
|
||||
|
||||
:::note
|
||||
Some Listenbrainz applications may require custom Listenbrainz URLs to be a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality. It is out of the scope of this project to do this solely within multi-scrobbler.
|
||||
:::
|
||||
|
||||
##### Token
|
||||
|
||||
Most Listenbrainz clients require a token (Authentication Token) to be provided during setup. This value can be anything you want, just make sure to use the same value for `token` in your multi-scrobbler configuration for the endpoint.
|
||||
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
|
||||
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
|
||||
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/endpointlz.json" language="json5">{ListenbrainzEndpointConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={ListenbrainzEndpointConfig} name="endpointlz"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [~~Deezer~~](https://deezer.com/)
|
||||
|
||||
:::warning
|
||||
@@ -1729,8 +1921,6 @@ If the file being played in VLC does not have ID3 tags/metadata information mult
|
||||
|
||||
To extract this information use [named capture groups](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group) in your expression matching the field that should be extracted: `artist` `title` and/or `album`. MS accepts multiple expressions, the first one that matches a filename will be used.
|
||||
|
||||
<!-- Would like to put this in a <details> node but the <> in the named capture groups are causing the parser to incorrectly parse them as an actual tag -->
|
||||
|
||||
Example usage in [a file-based config:](./?configType=file#webscrobbler-config)
|
||||
|
||||
```json5 title="config/vlc.json" {10-15}
|
||||
@@ -1773,6 +1963,224 @@ If you find that VLC is incorrectly reporting track information (in its interfac
|
||||
]
|
||||
```
|
||||
|
||||
### [Azuracast](https://www.azuracast.com/)
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Azuracast may not correctly report track length or position. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
|
||||
|
||||
:::
|
||||
|
||||
The Azuracast server should have **Use High-Performance Now Playing Updates** enabled in _Administration -> System Settings_
|
||||
|
||||
##### URL
|
||||
|
||||
The URL used by MS to connect to Azuracast has the syntax:
|
||||
|
||||
```
|
||||
[ws|wss]://HOST:[PORT]
|
||||
```
|
||||
|
||||
MS will automatically add the path required for websockets, [`/api/live/nowplaying/websocket`](https://www.azuracast.com/docs/developers/now-playing-data/#websockets), to your URL if none is provided. If you use a reverse proxy with a path-based URL or otherwise need a custom path to access the websockets path correctly then explicitly provide it. Examples:
|
||||
|
||||
```
|
||||
URL From Config => MS Uses
|
||||
|
||||
'ws://192.168.0.101' => ws://192.168.0.101/api/live/nowplaying/websocket
|
||||
'ws://azura.mydomain.com' => ws://azura.mydomain.com.com/api/live/nowplaying/websocket
|
||||
'wss://mydomain.com/custom/azura/ws' => wss://mydomain.com/custom/azura/ws
|
||||
```
|
||||
|
||||
##### Scrobble Behavior
|
||||
|
||||
Multi-scrobbler will set the default [**scrobbling behavior**](#should-scrobble-behavior) based on if either of these settings is configured:
|
||||
|
||||
* live status (`AZURA_LIVE` or `monitorWhenLive`) or
|
||||
* listener count (`AZURA_LISTENERS_NUM` or `monitorWhenListeners`)
|
||||
|
||||
If either is configured then MS will automatically scrobble based on when these conditions are met.
|
||||
|
||||
If neither is configured automatic scrobble defaults to **off** and you should either [manually initiate it or explicitly set the default behavior.](#should-scrobble-behavior)
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | :------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `AZ_URL` | Yes | | Azuracast URL *without station name* |
|
||||
| `AZ_STATION` | Yes | | The station name shown on the public page |
|
||||
| `AZURA_LIVE` | No | Yes | Only scrobble when station status is ONLINE |
|
||||
| `AZURA_LISTENERS_NUM` | No | `true` | Only scrobble if station has any listeners (`true`) or listeners are equal-to/greater-than `X` |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/azuracast.json" language="json5">{AzuracastConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={AzuracastConfig} name="azuracast"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Yamaha MusicCast](https://usa.yamaha.com/products/contents/audio_visual/musiccast/index.html)
|
||||
|
||||
Monitor Musiccast device/receivers for music played on Network/USB/CD inputs.
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Musiccast may not correctly report track length. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
|
||||
|
||||
:::
|
||||
|
||||
##### URL
|
||||
|
||||
If only a domain/IP:PORT is provided to multi-scrobbler it will automatically append the default Musiccast API path. If your Musiccast device is located behind a custom URL (or reverse proxy) you should explicitly provide the base path to use.
|
||||
|
||||
EX
|
||||
|
||||
| `url` | Transformed URL |
|
||||
| --------------------------------------- | :---------------------------------------------- |
|
||||
| `http://192.168.0.101` | `http://192.168.0.101/YamahaExtendedControl/v1` |
|
||||
| `http://mydomain.com/reverseProxy/cast` | `http://mydomain.com/reverseProxy/cast` |
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | :----------------------- |
|
||||
| `MCAST_URL` | Yes | | The Musiccast device URL |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/musiccast.json" language="json5">{MusiccastConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="MusicCastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={MusiccastConfig} name="musiccast"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="MusicCastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Icecast](https://icecast.org)
|
||||
|
||||
Parse metadata from any Icecast Station (streams) to use for scrobbling.
|
||||
|
||||
:::note
|
||||
|
||||
There is no standard format for presenting track information in an Icecast stream and the majority of streams that do include metadata only use a single string (`StreamTitle`) from which metadata can be parsed.
|
||||
|
||||
Multi-scrobbler will do its best to parse Artist/Album/Track information from this data but the quality and consistency for this is solely based on how the Icecast broadcaster chooses to format their tracks.
|
||||
|
||||
:::
|
||||
|
||||
:::tip[Scrobbling Behavior]
|
||||
|
||||
Multi-scrobbler cannot automatically determine if you are listening to a Station. Therefore, **automatic scrobbling for Icecast Sources is off by default.**
|
||||
|
||||
Use [Should Scrobble Behavior](#should-scrobble-behavior) to initiate scrobbling or configure automatic scrobble.
|
||||
|
||||
:::
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Icecast does not report track lengths so multi-scrobbler cannot determine if it should scrobble based on percent played. You should instead set a shorter "listened to" **duration** using [Scrobble Thresholds.](#scrobble-thresholds)
|
||||
|
||||
:::
|
||||
|
||||
|
||||
##### URL and Sources
|
||||
|
||||
The URL should be the URL you would use to stream audio.
|
||||
|
||||
Additional sources are supported based on those found in [`icecast-metadata-stats`](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#supported-status-apis). To use these with automatic parsing [based on the regular URL](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) include a `sources` array in your config the same way as shown in `icecast-metadata-stats`:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5 title="config.json"
|
||||
[
|
||||
{
|
||||
"name": "myIcy",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://myIcecast.com/radio",
|
||||
"sources": ["icy", "ogg", "sevenhtml"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
To manually set these sources include the same [`*Endpoint` options](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) found in `icecast-metadata-stats`.
|
||||
|
||||
If no `sources` are configured for multi-scrobbler it will automatically try to use `icy` and `ogg`.
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | :------ | ----------- |
|
||||
| `ICECAST_URL` | Yes | | Icecast URL |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/icecast.json" language="json5">{IcecastConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="IcecastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={IcecastConfig} name="icecast"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="IcecastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Client Configurations
|
||||
|
||||
### [Maloja](https://github.com/krateng/maloja)
|
||||
|
||||
@@ -132,13 +132,11 @@ Putting it all together:
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
[
|
||||
"badTerm",
|
||||
{
|
||||
"search": "badTerm",
|
||||
"replace": "goodTerm"
|
||||
}
|
||||
]
|
||||
"badTerm",
|
||||
{
|
||||
"search": "badTerm",
|
||||
"replace": "goodTerm"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -481,3 +479,45 @@ Removes the phrase `(Album Version)` from the Title of a Play
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Extract primary Artist from delimited, multi-Artist string
|
||||
|
||||
<details>
|
||||
|
||||
When the Artist string is actually a multi-artist, delimited string, this search-and-replace will replace the string with just the first artist found.
|
||||
|
||||
Ex
|
||||
|
||||
```
|
||||
My Artist One / My Artist Two / Another Guy
|
||||
My Artist One
|
||||
```
|
||||
|
||||
Artists are delimited with a spaced forward slash (`/`) in the regex below. Replace the contents of the `delim` capture group with the delimiter for your use case. Some more common scenarios:
|
||||
|
||||
* `(?<delim>\\/)` No spaces between slash IE `My Artist One/My Artist Two/Another Guy`
|
||||
* `(?<delim>\\s*\\\\\s*)` Backslash instead of forward slash IE `My Artist One \ My Artist Two \ Another Guy`
|
||||
* `(?<delim>,)` Comma IE `My Artist One, My Artist Two, Another Guy`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"artists": [
|
||||
{
|
||||
"search": "(.*?)(?<delim>\\s*\\/\\s*)(.*$)",
|
||||
"replace": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
</details>
|
||||
@@ -4,6 +4,9 @@ import * as themes from 'prism-react-renderer';
|
||||
//import sidebars from './sidebars';
|
||||
|
||||
const config: Config = {
|
||||
future: {
|
||||
experimental_faster: true
|
||||
},
|
||||
title: 'Multi-Scrobbler',
|
||||
tagline: 'Scrobble all the things',
|
||||
favicon: 'img/favicon.ico',
|
||||
|
||||
Generated
+5679
-2357
File diff suppressed because it is too large
Load Diff
+16
-15
@@ -3,20 +3,21 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "npx docusaurus start --host 0.0.0.0",
|
||||
"build": "npx docusaurus build",
|
||||
"swizzle": "npx docusaurus swizzle",
|
||||
"deploy": "npx docusaurus deploy",
|
||||
"clear": "npx docusaurus clear",
|
||||
"serve": "npx docusaurus serve",
|
||||
"write-translations": "npx docusaurus write-translations",
|
||||
"write-heading-ids": "npx docusaurus write-heading-ids",
|
||||
"start": "docusaurus start --host 0.0.0.0",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.4.0",
|
||||
"@docusaurus/preset-classic": "^3.4.0",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.44.3",
|
||||
"@docusaurus/core": "^3.6.3",
|
||||
"@docusaurus/faster": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.6.3",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.48.3",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-json-schema-plugin": "^1.12.1",
|
||||
@@ -29,11 +30,11 @@
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.4.0",
|
||||
"@docusaurus/tsconfig": "^3.4.0",
|
||||
"@docusaurus/types": "^3.4.0",
|
||||
"@docusaurus/module-type-aliases": "^3.6.3",
|
||||
"@docusaurus/tsconfig": "^3.6.3",
|
||||
"@docusaurus/types": "^3.6.3",
|
||||
"@types/react": "^18.2.29",
|
||||
"typescript": "~5.2.2"
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -20,7 +20,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [WebScrobbler](docs/configuration#webscrobbler)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
|
||||
* [~~Deezer~~](docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
* [Mopidy](docs/configuration#mopidy)
|
||||
@@ -30,6 +32,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Musikcube](docs/configuration#musikcube)
|
||||
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
|
||||
* [VLC](docs/configuration#vlc)
|
||||
* [Azuracast](docs/configuration#azuracast)
|
||||
* [Yamaha MusicCast](docs/configuration#yamaha-musiccast)
|
||||
* [Icecast](docs/configuration#icecast)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -51,6 +51,7 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.9.2" date="2025-03-24"/>
|
||||
<release version="0.9.0" date="2025-01-15"/>
|
||||
<release version="0.8.4" date="2024-09-27"/>
|
||||
<release version="0.8.3" date="2024-08-28"/>
|
||||
|
||||
Generated
+62
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -22,6 +22,7 @@
|
||||
"@foxxmd/logging": "^0.2.2",
|
||||
"@foxxmd/regex-buddy-core": "^0.1.2",
|
||||
"@foxxmd/string-sameness": "^0.4.0",
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@lukehagar/plexjs": "^0.32.1",
|
||||
@@ -52,6 +53,7 @@
|
||||
"glob": "^11.0.0",
|
||||
"google-auth-library": "^9.15.0",
|
||||
"gotify": "^1.1.0",
|
||||
"icecast-metadata-stats": "^0.1.12",
|
||||
"iso-websocket": "^0.3.0",
|
||||
"iti": "^0.6.0",
|
||||
"json5": "^2.2.3",
|
||||
@@ -82,7 +84,7 @@
|
||||
"vite-express": "^0.16.0",
|
||||
"vlc-client": "^1.1.1",
|
||||
"xml2js": "0.6.1",
|
||||
"youtubei.js": "^12.0.0"
|
||||
"youtubei.js": "^13.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
@@ -121,6 +123,7 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"mocha": "^10.3.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"msw": "^2.1.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"ts-essentials": "^9.1.2",
|
||||
@@ -1135,6 +1138,14 @@
|
||||
"npm": ">=9.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@gr2m/net-interceptor": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@gr2m/net-interceptor/-/net-interceptor-1.0.0.tgz",
|
||||
"integrity": "sha512-qLTp3npmB5SDiEIh1Jg1v8a09dlnfYV1kcSyLmsyucvZBT89nM+6L1QdlrCC/W1cTvdTpvI+Vleit94UVX7/Cg==",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@homebridge/long": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@homebridge/long/-/long-5.2.1.tgz",
|
||||
@@ -4097,6 +4108,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codec-parser": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/codec-parser/-/codec-parser-2.5.0.tgz",
|
||||
"integrity": "sha512-Ru9t80fV8B0ZiixQl8xhMTLru+dzuis/KQld32/x5T/+3LwZb0/YvQdSKytX9JqCnRdiupvAvyYJINKrXieziQ=="
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -6026,6 +6042,33 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/icecast-metadata-js": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/icecast-metadata-js/-/icecast-metadata-js-1.2.9.tgz",
|
||||
"integrity": "sha512-8YqPrJ4AjM64O28xF9TSUUFczxnTKwXwnIPmZKRxdbaZb6hn0nP+ke1OGNA+UsIfLpNRW4acDDBkIkbynYVQig==",
|
||||
"dependencies": {
|
||||
"codec-parser": "2.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/eshaz"
|
||||
}
|
||||
},
|
||||
"node_modules/icecast-metadata-stats": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/icecast-metadata-stats/-/icecast-metadata-stats-0.1.12.tgz",
|
||||
"integrity": "sha512-qywYIIvxjAmZIFNUXMVZ/IgIJh87z0W6oOmJ5htPw3SUauXcYoY6rRexvzN5Ibct8hXsqoTcB+k8m6Wa53bfJg==",
|
||||
"dependencies": {
|
||||
"icecast-metadata-js": "1.2.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/eshaz"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -6810,9 +6853,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jintr": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.1.0.tgz",
|
||||
"integrity": "sha512-azhCHApkRfBH8INpiUCwKBYaNCdB5G+x3NApsI2MxQXSlgFAx7rap3YwE3JAkN08GO8f3ilZsGB0Yvc+412ntQ==",
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.2.1.tgz",
|
||||
"integrity": "sha512-yjKUBuwTTg4nc4izMysxuIk0BKh45hnbc1KnXE6LxagIGZn5od+I2elpuRY9IIm3EiKiUZxhxV89a0iX+xoEZg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
@@ -7393,6 +7436,12 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/mockdate": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
|
||||
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mopidy": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/mopidy/-/mopidy-1.3.0.tgz",
|
||||
@@ -11746,9 +11795,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
@@ -11922,15 +11971,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/youtubei.js": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-12.0.0.tgz",
|
||||
"integrity": "sha512-pGmVb1I9b2gseqmuMx+BCajzVUi04+r+8zxj4Fk/iQaGQGvBCbY87Tu9mdvEgIQYTkkb4Fza7GZGrH9AjYNbrw==",
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-13.1.0.tgz",
|
||||
"integrity": "sha512-uL4TyojAYET0c5NGFD7+ScCod/k8Pc/B+D5tLrunFcz1GaBjRMOGRPcNGaRmnhwisegU7ibtw0iUxCN+BZ0ang==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.0.0",
|
||||
"jintr": "^3.1.0",
|
||||
"jintr": "^3.2.1",
|
||||
"tslib": "^2.5.0",
|
||||
"undici": "^5.19.1"
|
||||
}
|
||||
|
||||
+10
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.2",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
@@ -13,10 +13,12 @@
|
||||
"build:frontend": "vite build",
|
||||
"build:backend": "tsc -p src/backend",
|
||||
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
|
||||
"docs:install": "cd docsite && npm ci",
|
||||
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
|
||||
"docs:install": "cd docsite && npm ci --no-audit",
|
||||
"docs:start": "cd docsite && npm start",
|
||||
"docs:build": "cd docsite && npm run build",
|
||||
"postinstall": "patch-package"
|
||||
"docs:build": "npm run schema && cd docsite && npm run build",
|
||||
"postinstall": "patch-package",
|
||||
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\""
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -52,6 +54,7 @@
|
||||
"@foxxmd/logging": "^0.2.2",
|
||||
"@foxxmd/regex-buddy-core": "^0.1.2",
|
||||
"@foxxmd/string-sameness": "^0.4.0",
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@lukehagar/plexjs": "^0.32.1",
|
||||
@@ -82,6 +85,7 @@
|
||||
"glob": "^11.0.0",
|
||||
"google-auth-library": "^9.15.0",
|
||||
"gotify": "^1.1.0",
|
||||
"icecast-metadata-stats": "^0.1.12",
|
||||
"iso-websocket": "^0.3.0",
|
||||
"iti": "^0.6.0",
|
||||
"json5": "^2.2.3",
|
||||
@@ -112,7 +116,7 @@
|
||||
"vite-express": "^0.16.0",
|
||||
"vlc-client": "^1.1.1",
|
||||
"xml2js": "0.6.1",
|
||||
"youtubei.js": "^12.0.0"
|
||||
"youtubei.js": "^13.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
@@ -151,6 +155,7 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"mocha": "^10.3.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"msw": "^2.1.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"ts-essentials": "^9.1.2",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
diff --git a/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js b/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
|
||||
index 7b70725..8836a99 100644
|
||||
--- a/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
|
||||
+++ b/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
|
||||
@@ -59,6 +59,7 @@ const sources = Symbol();
|
||||
const interval = Symbol();
|
||||
const onStats = Symbol();
|
||||
const onStatsFetch = Symbol();
|
||||
+const onError = Symbol();
|
||||
|
||||
const state = Symbol();
|
||||
const intervalId = Symbol();
|
||||
@@ -106,6 +107,7 @@ export default class IcecastMetadataStats {
|
||||
[interval]: (options.interval || 30) * 1000,
|
||||
[onStats]: options.onStats || noOp,
|
||||
[onStatsFetch]: options.onStatsFetch || noOp,
|
||||
+ [onError]: options.onError,
|
||||
[icyMetaInt]: options.icyMetaInt,
|
||||
[icyCharacterEncoding]: options.icyCharacterEncoding,
|
||||
[icyDetectionTimeout]: options.icyDetectionTimeout,
|
||||
@@ -425,13 +427,21 @@ export default class IcecastMetadataStats {
|
||||
signal: p.get(this)[controller].signal,
|
||||
})
|
||||
.then((res) => {
|
||||
- if (!res.ok) throw new Error(`HTTP Error ${res.status}`);
|
||||
+ if (!res.ok) {
|
||||
+ const responseError = new Error(`HTTP Error ${res.status}`);
|
||||
+ responseError.res = res;
|
||||
+ throw responseError;
|
||||
+ }
|
||||
return res;
|
||||
})
|
||||
.then(mapper)
|
||||
.catch((e) => {
|
||||
if (e.name !== "AbortError") {
|
||||
- console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e);
|
||||
+ if(p.get(this)[onError] !== undefined) {
|
||||
+ p.get(this)[onError](new Error(`Failed to fetch ${p.get(this)[endpoint]}`, {cause: e}));
|
||||
+ } else {
|
||||
+ console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e);
|
||||
+ }
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -15,9 +15,12 @@ export type SourceType =
|
||||
| 'jellyfin'
|
||||
| 'lastfm'
|
||||
| 'deezer'
|
||||
| 'endpointlz'
|
||||
| 'endpointlfm'
|
||||
| 'ytmusic'
|
||||
| 'mpris'
|
||||
| 'mopidy'
|
||||
| 'musiccast'
|
||||
| 'listenbrainz'
|
||||
| 'jriver'
|
||||
| 'kodi'
|
||||
@@ -25,7 +28,9 @@ export type SourceType =
|
||||
| 'chromecast'
|
||||
| 'musikcube'
|
||||
| 'mpd'
|
||||
| 'vlc';
|
||||
| 'vlc'
|
||||
| 'icecast'
|
||||
| 'azuracast';
|
||||
|
||||
export const sourceTypes: SourceType[] = [
|
||||
'spotify',
|
||||
@@ -35,9 +40,12 @@ export const sourceTypes: SourceType[] = [
|
||||
'jellyfin',
|
||||
'lastfm',
|
||||
'deezer',
|
||||
'endpointlz',
|
||||
'endpointlfm',
|
||||
'ytmusic',
|
||||
'mpris',
|
||||
'mopidy',
|
||||
'musiccast',
|
||||
'listenbrainz',
|
||||
'jriver',
|
||||
'kodi',
|
||||
@@ -45,7 +53,9 @@ export const sourceTypes: SourceType[] = [
|
||||
'chromecast',
|
||||
'musikcube',
|
||||
'mpd',
|
||||
'vlc'
|
||||
'vlc',
|
||||
'icecast',
|
||||
'azuracast'
|
||||
];
|
||||
|
||||
export const isSourceType = (data: string): data is SourceType => {
|
||||
|
||||
@@ -48,5 +48,32 @@ export interface PollingOptions {
|
||||
* @examples [30]
|
||||
* */
|
||||
maxInterval?: number
|
||||
|
||||
/**
|
||||
* Number of seconds after which A Player is considered Stale
|
||||
*
|
||||
* When Polling the source does not recieve data about a specific Player after X seconds it becomes Stale. When the Player becomes Stale:
|
||||
*
|
||||
* * The current listening session is ended. If the Player becomes active again a new listening session is started (Player will miss `interval` seconds of listening)
|
||||
* * If the player has an existing session w/ track then MS attempts to scrobble it
|
||||
*
|
||||
* This option DOES NOT need to be set. It is automatically calculated as (`interval` * 3) when not defined.
|
||||
*/
|
||||
staleAfter?: number
|
||||
|
||||
/**
|
||||
* Number of seconds after which A Player is considered Orphaned
|
||||
*
|
||||
* When Polling the source does not recieve data about a specific Player after X seconds it becomes Orphaned. When the Player becomes Orphaned:
|
||||
*
|
||||
* * The current Player session is ended and the Player is removed from MS
|
||||
* * MS attempts to scrobble, if the Player has an existing session w/ track
|
||||
*
|
||||
* A Player should become Orphaned EQUAL TO OR AFTER it becomes Stale.
|
||||
*
|
||||
* * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 5) when not defined.
|
||||
* * If it is set it must be equal to or larger than `staleAfter` or (`interval * 3`)
|
||||
*/
|
||||
orphanedAfter?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js";
|
||||
|
||||
export interface AzuraStationInfoResponse {
|
||||
id: string
|
||||
name: string
|
||||
shortcode: string
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
export interface AzuraListenersResponse {
|
||||
total: number
|
||||
unique: number
|
||||
current: number
|
||||
}
|
||||
|
||||
export interface AzuraSongResponse {
|
||||
id: string
|
||||
text: string
|
||||
artist: string
|
||||
title: string
|
||||
album: string
|
||||
genre: string
|
||||
isrc: string
|
||||
}
|
||||
|
||||
export interface AzuraNowPlayingResponse {
|
||||
sh_id: number
|
||||
played_at: number
|
||||
duration: number
|
||||
streamer: string
|
||||
elapsed: number
|
||||
remaining: number
|
||||
song: AzuraSongResponse
|
||||
}
|
||||
|
||||
export interface AzuraLiveResponse {
|
||||
is_live: boolean
|
||||
streamer_name: string
|
||||
broadcast_start: number | null
|
||||
}
|
||||
|
||||
export interface AzuraStationResponse {
|
||||
is_online: boolean
|
||||
station: AzuraStationInfoResponse
|
||||
listeners: AzuraListenersResponse
|
||||
now_playing: AzuraNowPlayingResponse
|
||||
}
|
||||
|
||||
|
||||
export interface AzuracastData extends CommonSourceData {
|
||||
/**
|
||||
* Base URL of the Azuracast instance
|
||||
*
|
||||
* This does NOT include the station. If a station is included it will be ignored. Use `station` field to specify station, if necessary
|
||||
*
|
||||
*
|
||||
* @examples ["https://radio.mydomain.tld", "http://localhost:80"]
|
||||
* */
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The specific station to monitor
|
||||
*
|
||||
* Scrobbling will only occur if any of the monitor conditions are met AND the station is ONLINE.
|
||||
*
|
||||
* To monitor multiple stations create a Source for each station.
|
||||
*
|
||||
* @examples ["my-station-1"]
|
||||
* */
|
||||
station: string
|
||||
|
||||
/**
|
||||
* Only activate scrobble monitoring if station
|
||||
*
|
||||
* * `true` => has any current listeners
|
||||
* * `number` => has EQUAL TO or MORE THAN X number of listeners
|
||||
*
|
||||
*/
|
||||
monitorWhenListeners?: boolean | number
|
||||
|
||||
/**
|
||||
* Only activate scrobble monitoring if station has a live DJ/Streamer
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
monitorWhenLive?: boolean
|
||||
|
||||
/**
|
||||
* API Key used to access data about private streams
|
||||
*
|
||||
* https://www.azuracast.com/docs/developers/apis/#api-authentication
|
||||
* */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface AzuracastSourceoptions extends CommonSourceOptions, ManualListeningOptions {
|
||||
|
||||
}
|
||||
|
||||
export interface AzuracastSourceConfig extends CommonSourceConfig {
|
||||
data: AzuracastData
|
||||
}
|
||||
|
||||
export interface AzuracastSourceAIOConfig extends AzuracastSourceConfig {
|
||||
type: 'azuracast'
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface LastFMEndpointData extends CommonSourceData {
|
||||
/**
|
||||
* The URL ending that should be used to identify scrobbles for this source
|
||||
*
|
||||
* If you are using multiple Last.fm endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* * slug: 'usera' => API URL: http://localhost:9078/api/lastfm/usera
|
||||
* * slug: 'originb' => API URL: http://localhost:9078/api/lastfm/originb
|
||||
*
|
||||
* If no slug is found from an extension's incoming webhook event the first Last.fm source without a slug will be used
|
||||
* */
|
||||
slug?: string | null
|
||||
}
|
||||
|
||||
export interface LastFMEndpointSourceConfig extends CommonSourceConfig {
|
||||
data?: LastFMEndpointData
|
||||
}
|
||||
|
||||
export interface LastFMEndpointSourceAIOConfig extends LastFMEndpointSourceConfig {
|
||||
type: 'endpointlfm'
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface ListenbrainzEndpointData extends CommonSourceData {
|
||||
/**
|
||||
* The URL ending that should be used to identify scrobbles for this source
|
||||
*
|
||||
* If you are using multiple Listenbrainz endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* * slug: 'usera' => API URL: http://localhost:9078/api/listenbrainz/usera
|
||||
* * slug: 'originb' => API URL: http://localhost:9078/api/listenbrainz/originb
|
||||
*
|
||||
* If no slug is found from an extension's incoming webhook event the first Listenbrainz source without a slug will be used
|
||||
* */
|
||||
slug?: string | null
|
||||
|
||||
/**
|
||||
* If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source
|
||||
*
|
||||
* See: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#add-the-user-token-to-your-requests
|
||||
* */
|
||||
token?: string | null
|
||||
}
|
||||
|
||||
export interface ListenbrainzEndpointSourceConfig extends CommonSourceConfig {
|
||||
data?: ListenbrainzEndpointData
|
||||
}
|
||||
|
||||
export interface ListenbrainzEndpointSourceAIOConfig extends ListenbrainzEndpointSourceConfig {
|
||||
type: 'endpointlz'
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js";
|
||||
|
||||
|
||||
export interface IcecastMetadata {
|
||||
icy?: {
|
||||
/** Title of the ICY metadata update, usually Artist - Title */
|
||||
StreamTitle?: string
|
||||
/** URL of the ICY metadata update, usually album art */
|
||||
StreamUrl?: string
|
||||
}
|
||||
ogg?: {
|
||||
/** Title of the OGG metadata update, usually Artist - Title */
|
||||
TITLE?: string
|
||||
ALBUM?: string
|
||||
ARTIST?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type IcecastSource = 'icy' | 'ogg' | 'icestats' | 'stats' | 'sevenhtml' | 'nextsongs';
|
||||
|
||||
export interface IcecastOptions {
|
||||
sources?: IcecastSource[]
|
||||
icestatsEndpoint?: string
|
||||
statsEndpoint?: string
|
||||
nextsongsEndpoint?: string
|
||||
sevenhtmlEndpoint?: string
|
||||
icyMetaInt?: number
|
||||
}
|
||||
|
||||
export interface IcecastData extends CommonSourceData, IcecastOptions {
|
||||
/**
|
||||
* The Icecast stream URL
|
||||
* */
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface IcecastSourceOptions extends CommonSourceOptions, ManualListeningOptions {
|
||||
}
|
||||
|
||||
export interface IcecastSourceConfig extends CommonSourceConfig {
|
||||
data: IcecastData
|
||||
options?: IcecastSourceOptions
|
||||
}
|
||||
|
||||
export interface IcecastSourceAIOConfig extends IcecastSourceConfig {
|
||||
type: 'icecast'
|
||||
}
|
||||
@@ -109,6 +109,15 @@ export interface CommonSourceOptions extends SourceRetryOptions {
|
||||
playTransform?: PlayTransformOptions
|
||||
}
|
||||
|
||||
export interface ManualListeningOptions {
|
||||
/**
|
||||
* For Sources that support manual listening, should MS default to scrobbling when no user interaction has occurred?
|
||||
*
|
||||
* If not specified MS will use a Source's specific behavior, see Source's documentation.
|
||||
*/
|
||||
systemScrobble?: boolean
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData {
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../Atomic.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
|
||||
|
||||
export interface MusicCastResponse {
|
||||
response_code: number
|
||||
}
|
||||
|
||||
export interface DeviceInfoResponse extends MusicCastResponse {
|
||||
model_name: string
|
||||
device_id: string
|
||||
system_version: number
|
||||
api_version: number
|
||||
}
|
||||
|
||||
export interface DeviceStatusResponse extends MusicCastResponse {
|
||||
power: 'on' | 'standby'
|
||||
}
|
||||
|
||||
/** use with /netusb/getPlayInfo or /cd/getPlayInfo */
|
||||
export interface PlayInfoCDResponse extends MusicCastResponse {
|
||||
device_status: 'open' | 'close' | 'ready' | 'not_ready'
|
||||
playback: 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
|
||||
/** in seconds */
|
||||
play_time: number
|
||||
/** in seconds */
|
||||
total_time: number
|
||||
artist: string
|
||||
album: string
|
||||
track: string
|
||||
}
|
||||
|
||||
export interface PlayInfoNetResponse extends PlayInfoCDResponse {
|
||||
input: string
|
||||
}
|
||||
|
||||
export const MusicCastResponseCodes = new Map<number, string>([
|
||||
[0, 'Success'],
|
||||
[1, 'Initializing'],
|
||||
[2, 'Internal Error'],
|
||||
[3, 'Invalid Request'],
|
||||
[4, 'Invalid Parameter'],
|
||||
[5, 'Guarded (Unable to setup in current status)'],
|
||||
[6, 'Time out'],
|
||||
[100, 'Access Error'],
|
||||
[101, 'Other Error'],
|
||||
[107, 'Service Maintenance'],
|
||||
[109, 'License Error'],
|
||||
[110, 'Read Only Mode'],
|
||||
[112, 'Access Denied'],
|
||||
[115, 'Simultaneous logins has reached the upper limit'],
|
||||
[200, 'Linking in progress'],
|
||||
[201, 'Unlinking in progress']
|
||||
]);
|
||||
|
||||
export const playbackToReportedStatus = (pb: PlaybackStatus): ReportedPlayerStatus => {
|
||||
switch(pb) {
|
||||
case 'play':
|
||||
case 'fast_forward':
|
||||
case 'fast_reverse':
|
||||
return REPORTED_PLAYER_STATUSES.playing;
|
||||
case 'pause':
|
||||
return REPORTED_PLAYER_STATUSES.paused;
|
||||
case 'stop':
|
||||
return REPORTED_PLAYER_STATUSES.stopped;
|
||||
default:
|
||||
return REPORTED_PLAYER_STATUSES.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MusicCastData extends CommonSourceData {
|
||||
/**
|
||||
* The host or URL of the YamahaExtendedControl endpoint to use
|
||||
*
|
||||
* @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]]
|
||||
* */
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface MusicCastSourceConfig extends CommonSourceConfig {
|
||||
data: MusicCastData
|
||||
}
|
||||
|
||||
export interface MusicCastSourceAIOConfig extends MusicCastSourceConfig {
|
||||
type: 'musiccast'
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js";
|
||||
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
|
||||
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
|
||||
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
|
||||
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
|
||||
import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
|
||||
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
|
||||
@@ -9,6 +12,7 @@ import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
|
||||
import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js";
|
||||
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
|
||||
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
|
||||
import { MusicCastSourceConfig, MusicCastSourceAIOConfig } from "./musiccast.js";
|
||||
import { PlexSourceAIOConfig, PlexSourceConfig, PlexApiSourceConfig, PlexApiSourceAIOConfig } from "./plex.js";
|
||||
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
|
||||
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
|
||||
@@ -16,6 +20,7 @@ import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
|
||||
import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js";
|
||||
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
|
||||
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
|
||||
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
|
||||
|
||||
|
||||
export type SourceConfig =
|
||||
@@ -24,6 +29,8 @@ export type SourceConfig =
|
||||
| PlexApiSourceConfig
|
||||
| TautulliSourceConfig
|
||||
| DeezerSourceConfig
|
||||
| ListenbrainzEndpointSourceConfig
|
||||
| LastFMEndpointSourceConfig
|
||||
| SubSonicSourceConfig
|
||||
| JellySourceConfig
|
||||
| JellyApiSourceConfig
|
||||
@@ -37,8 +44,11 @@ export type SourceConfig =
|
||||
| WebScrobblerSourceConfig
|
||||
| ChromecastSourceConfig
|
||||
| MusikcubeSourceConfig
|
||||
| MusicCastSourceConfig
|
||||
| MPDSourceConfig
|
||||
| VLCSourceConfig;
|
||||
| VLCSourceConfig
|
||||
| IcecastSourceConfig
|
||||
| AzuracastSourceConfig;
|
||||
|
||||
export type SourceAIOConfig =
|
||||
SpotifySourceAIOConfig
|
||||
@@ -46,6 +56,8 @@ export type SourceAIOConfig =
|
||||
| PlexApiSourceAIOConfig
|
||||
| TautulliSourceAIOConfig
|
||||
| DeezerSourceAIOConfig
|
||||
| ListenbrainzEndpointSourceAIOConfig
|
||||
| LastFMEndpointSourceAIOConfig
|
||||
| SubsonicSourceAIOConfig
|
||||
| JellySourceAIOConfig
|
||||
| JellyApiSourceAIOConfig
|
||||
@@ -59,5 +71,8 @@ export type SourceAIOConfig =
|
||||
| WebScrobblerSourceAIOConfig
|
||||
| ChromecastSourceAIOConfig
|
||||
| MusikcubeSourceAIOConfig
|
||||
| MusicCastSourceAIOConfig
|
||||
| MPDSourceAIOConfig
|
||||
| VLCSourceAIOConfig;
|
||||
| VLCSourceAIOConfig
|
||||
| IcecastSourceAIOConfig
|
||||
| AzuracastSourceAIOConfig;
|
||||
|
||||
@@ -30,6 +30,10 @@ declare module 'lastfm-node-client' {
|
||||
mbid?: string
|
||||
}
|
||||
|
||||
export interface TrackScrobbleRequest extends TrackScrobblePayload {
|
||||
method: 'track.scrobble'
|
||||
}
|
||||
|
||||
export interface TrackScrobbleResponse {
|
||||
scrobbles: {
|
||||
'@attr': {
|
||||
@@ -52,6 +56,10 @@ declare module 'lastfm-node-client' {
|
||||
|
||||
export type NowPlayingPayload = Omit<TrackScrobblePayload, 'mbid' | 'timestamp'>
|
||||
|
||||
export type NowPlayingRequest = NowPlayingPayload & { method: 'track.updateNowPlaying' };
|
||||
|
||||
export type LastfmTrackUpdateRequest = TrackScrobbleRequest | NowPlayingRequest;
|
||||
|
||||
export interface NowPlayingResponse {
|
||||
nowplaying: {
|
||||
ignoredMessage: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+67
-2
@@ -1,6 +1,8 @@
|
||||
import dayjs from "dayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import LastFm, {
|
||||
AuthGetSessionResponse,
|
||||
LastfmTrackUpdateRequest,
|
||||
NowPlayingPayload,
|
||||
NowPlayingResponse,
|
||||
TrackObject,
|
||||
TrackScrobblePayload,
|
||||
@@ -16,6 +18,7 @@ import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { LastfmData } from "../infrastructure/config/client/lastfm.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import { parseArtistCredits } from "../../utils/StringUtils.js";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
@@ -224,13 +227,18 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
artist = artists[0];
|
||||
}
|
||||
|
||||
const additionalRichPayload: Partial<TrackScrobblePayload> = {};
|
||||
if(duration !== 0) {
|
||||
additionalRichPayload.duration = duration;
|
||||
}
|
||||
|
||||
const rawPayload: TrackScrobblePayload = {
|
||||
artist: artist,
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: getScrobbleTsSOCDate(playObj).unix(),
|
||||
mbid,
|
||||
...additionalRichPayload
|
||||
};
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
@@ -275,3 +283,60 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject => {
|
||||
const {
|
||||
artist,
|
||||
track,
|
||||
duration,
|
||||
album,
|
||||
albumArtist,
|
||||
} = obj;
|
||||
|
||||
let ts: Dayjs | undefined;
|
||||
if('timestamp' in obj) {
|
||||
ts = dayjs.unix(obj.timestamp);
|
||||
}
|
||||
const mbid = 'mbid' in obj ? obj.mbid : undefined;
|
||||
|
||||
|
||||
let artists: string[] = [];
|
||||
|
||||
const credits = parseArtistCredits(artist);
|
||||
if(credits !== undefined) {
|
||||
artists.push(credits.primary);
|
||||
if(credits.secondary !== undefined) {
|
||||
const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== '');
|
||||
if(nonEmptyArtists.length > 0) {
|
||||
artists = [...artists, ...nonEmptyArtists];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
artists = [artist];
|
||||
}
|
||||
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
track,
|
||||
album: nonEmptyStringOrDefault(album),
|
||||
albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [albumArtist] : undefined,
|
||||
duration: typeof duration === 'string' ? parseInt(duration, 10) : duration,
|
||||
playDate: ts,
|
||||
artists
|
||||
},
|
||||
meta: {
|
||||
source: 'lastfm',
|
||||
nowPlaying: obj.method === 'track.updateNowPlaying'
|
||||
}
|
||||
};
|
||||
|
||||
if(nonEmptyStringOrDefault(mbid) !== undefined) {
|
||||
play.data.meta = {
|
||||
brainz: {
|
||||
track: mbid
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return play;
|
||||
}
|
||||
+38
-1
@@ -81,7 +81,7 @@ export interface ListenPayload {
|
||||
}
|
||||
|
||||
export interface SubmitPayload {
|
||||
listen_type: 'single',
|
||||
listen_type: 'single' | 'playing_now',
|
||||
payload: [ListenPayload]
|
||||
}
|
||||
|
||||
@@ -300,6 +300,43 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject {
|
||||
const {
|
||||
listened_at = dayjs().unix(),
|
||||
track_metadata: {
|
||||
artist_name,
|
||||
track_name,
|
||||
additional_info: {
|
||||
duration,
|
||||
track_mbid,
|
||||
artist_mbids,
|
||||
release_mbid,
|
||||
release_group_mbid
|
||||
} = {}
|
||||
} = {},
|
||||
} = payload;
|
||||
|
||||
return {
|
||||
data: {
|
||||
playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at),
|
||||
track: track_name,
|
||||
artists: [artist_name],
|
||||
duration,
|
||||
meta: {
|
||||
brainz: {
|
||||
artist: artist_mbids !== undefined ? artist_mbids : undefined,
|
||||
album: release_mbid,
|
||||
albumArtist: release_group_mbid,
|
||||
track: track_mbid
|
||||
}
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
nowPlaying,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static listenResponseToPlay(listen: ListenResponse): PlayObject {
|
||||
const {
|
||||
listened_at,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { childLogger } from "@foxxmd/logging";
|
||||
import { URLData } from "../../../../core/Atomic.js";
|
||||
import { joinedUrl, normalizeWSAddress } from "../../../utils/NetworkUtils.js";
|
||||
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
|
||||
import { AzuracastData, AzuraStationResponse } from "../../infrastructure/config/source/azuracast.js";
|
||||
import AbstractApiClient from "../AbstractApiClient.js";
|
||||
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
|
||||
|
||||
|
||||
export class AzuracastApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: AzuracastData
|
||||
|
||||
urlData: URLData;
|
||||
|
||||
wsNowPlaying: AzuraStationResponse
|
||||
wsCurrenTime: number = 0;
|
||||
socket!: WS;
|
||||
|
||||
constructor(name: any, config: AzuracastData, options: AbstractApiOptions) {
|
||||
super('Azuracast API', name, config, options);
|
||||
|
||||
this.urlData = normalizeWSAddress(config.url);
|
||||
}
|
||||
|
||||
connectWS() {
|
||||
|
||||
const url = joinedUrl(this.urlData.url, '/api/live/nowplaying/websocket');
|
||||
const socket = new WebSocket(url);
|
||||
|
||||
socket.onopen = (e) => {
|
||||
socket.send(JSON.stringify({
|
||||
subs: {
|
||||
[`station:${this.config.station}`]: {"recover": true}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
socket.onerror = (e) => {
|
||||
this.logger.error(e);
|
||||
}
|
||||
|
||||
// Handle a now-playing event from a station. Update your now-playing data accordingly.
|
||||
function handleSseData(ssePayload, useTime = true) {
|
||||
const jsonData = ssePayload.data;
|
||||
|
||||
if (useTime && 'current_time' in jsonData) {
|
||||
this.wsCurrenTime = jsonData.current_time;
|
||||
}
|
||||
|
||||
this.wsNowPlaying = jsonData.np as AzuraStationResponse;
|
||||
}
|
||||
|
||||
socket.onmessage = (e) => {
|
||||
|
||||
const jsonData = JSON.parse(e.data as string);
|
||||
|
||||
if ('connect' in jsonData) {
|
||||
const connectData = jsonData.connect;
|
||||
|
||||
if ('data' in connectData) {
|
||||
// Legacy SSE data
|
||||
connectData.data.forEach(
|
||||
(initialRow) => handleSseData(initialRow)
|
||||
);
|
||||
} else {
|
||||
// New Centrifugo time format
|
||||
if ('time' in connectData) {
|
||||
this.wsCurrenTime = Math.floor(connectData.time / 1000);
|
||||
}
|
||||
|
||||
// New Centrifugo cached NowPlaying initial push.
|
||||
for (const subName in connectData.subs) {
|
||||
const sub = connectData.subs[subName];
|
||||
if ('publications' in sub && sub.publications.length > 0) {
|
||||
sub.publications.forEach((initialRow) => handleSseData(initialRow, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ('pub' in jsonData) {
|
||||
handleSseData(jsonData.pub);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export default class ScrobbleClients {
|
||||
getByType = (type: any) => this.clients.filter(x => x.type === type)
|
||||
|
||||
async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> {
|
||||
let clients: AbstractScrobbleClient[];
|
||||
let clients: AbstractScrobbleClient[] = [];
|
||||
|
||||
const messages: string[] = [];
|
||||
let clientsReady = true;
|
||||
@@ -63,7 +63,10 @@ export default class ScrobbleClients {
|
||||
if(type !== undefined) {
|
||||
clients = this.getByType(type);
|
||||
} else if(name !== undefined) {
|
||||
clients = [this.getByName(name)];
|
||||
const clientByName = this.getByName(name);
|
||||
if(clientByName !== undefined) {
|
||||
clients = [clientByName];
|
||||
}
|
||||
} else {
|
||||
clients = this.clients;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,12 @@ import { parseBool, sortByNewestPlayDate } from "../utils.js";
|
||||
import { setupAuthRoutes } from "./auth.js";
|
||||
import { setupDeezerRoutes } from "./deezerRoutes.js";
|
||||
import { setupJellyfinRoutes } from "./jellyfinRoutes.js";
|
||||
import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.js";
|
||||
import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.js";
|
||||
import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js";
|
||||
import { setupPlexRoutes } from "./plexRoutes.js";
|
||||
import { setupTautulliRoutes } from "./tautulliRoutes.js";
|
||||
import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js";
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
const maxBufferSize = 300;
|
||||
const output: Record<number, FixedSizeList<LogDataPretty>> = {};
|
||||
@@ -152,6 +153,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
setupJellyfinRoutes(app, logger, scrobbleSources);
|
||||
setupDeezerRoutes(app, logger, scrobbleSources);
|
||||
setupWebscrobblerRoutes(app, logger, scrobbleSources);
|
||||
setupLZEndpointRoutes(app, logger, scrobbleSources);
|
||||
setupLastfmEndpointRoutes(app, logger, scrobbleSources);
|
||||
setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
|
||||
|
||||
app.putAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
|
||||
@@ -192,6 +195,9 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
players: 'players' in x ? (x as MemorySource).playersToObject() : {},
|
||||
sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY,
|
||||
supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed,
|
||||
supportsManualListening: x.supportsManualListening,
|
||||
manualListening: x.manualListening,
|
||||
systemListeningBehavior: x.getSystemListeningBehavior(),
|
||||
...x.additionalApiData()
|
||||
};
|
||||
if(!x.isReady()) {
|
||||
@@ -445,6 +451,34 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/source/listen', sourceRequiredMiddle);
|
||||
app.postAsync('/api/source/listen', 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;
|
||||
|
||||
const {
|
||||
query: {
|
||||
listening: listeningQ
|
||||
}
|
||||
} = req;
|
||||
|
||||
if(!source.supportsManualListening)
|
||||
{
|
||||
source.logger.warn('This source does not support manual Should Scrobble state');
|
||||
res.status(400).send();
|
||||
return;
|
||||
}
|
||||
let listening: boolean | undefined;
|
||||
if(listeningQ !== undefined) {
|
||||
listening = parseBool(listeningQ)
|
||||
}
|
||||
source.logger.verbose(`User requested Should Scrobble status ${listening === undefined ? 'system' : listening}`);
|
||||
|
||||
source.manualListening = listening;
|
||||
|
||||
res.status(200).json({listening});
|
||||
});
|
||||
|
||||
app.use('/api/client/init', clientRequiredMiddle);
|
||||
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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import bodyParser from "body-parser";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { nonEmptyBody } from "./middleware.js";
|
||||
import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNotifier.js";
|
||||
import { EndpointLastfmSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointLastfmSource.js";
|
||||
import { LastfmTrackUpdateRequest } from "lastfm-node-client";
|
||||
|
||||
export const setupLastfmEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
const logger = childLogger(parentLogger, ['Ingress', 'LFM']);
|
||||
|
||||
const nonEmptyCheck = nonEmptyBody(logger, 'LFM Endpoint');
|
||||
|
||||
const webhookIngress = new LFMEndpointNotifier(logger);
|
||||
app.useAsync(/(\/api\/lastfm(?!\/callback))|(\/2.0\/?)$/,
|
||||
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)
|
||||
webhookIngress.trackIngress(req, true);
|
||||
if (req.method !== 'POST') {
|
||||
return res.sendStatus(405);
|
||||
}
|
||||
next();
|
||||
},
|
||||
bodyParser.urlencoded({ extended: true }),
|
||||
nonEmptyCheck, async function (req, res) {
|
||||
webhookIngress.trackIngress(req, false);
|
||||
|
||||
const sources = scrobbleSources.getByType('endpointlfm') as EndpointLastfmSource[];
|
||||
if (sources.length === 0) {
|
||||
logger.warn('Received Lastfm endpoint payload but no Lastfm endpoint sources are configured');
|
||||
}
|
||||
|
||||
const validSources = sources.filter(x => x.matchRequest(req));
|
||||
if (validSources.length === 0) {
|
||||
const [slug] = parseDisplayIdentifiersFromRequest(req);
|
||||
logger.warn(`No Lastfm endpoint config matched => Slug: ${slug}`);
|
||||
}
|
||||
|
||||
if(!('method' in req.body)) {
|
||||
return res.status(400).json({error: `Missing 'method' param`});
|
||||
}
|
||||
const method = (req.body as LastfmTrackUpdateRequest).method;
|
||||
if(!['track.updateNowPlaying','track.scrobble'].includes(method)) {
|
||||
return res.status(400).json({error: `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'`});
|
||||
}
|
||||
|
||||
res.sendStatus(200);
|
||||
|
||||
const playerState = playStateFromRequest(req.body);
|
||||
|
||||
for (const source of validSources) {
|
||||
await source.handle(playerState);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import bodyParser from "body-parser";
|
||||
import { EndpointListenbrainzSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointListenbrainzSource.js";
|
||||
import { LZEndpointNotifier } from "../sources/ingressNotifiers/LZEndpointNotifier.js";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { nonEmptyBody } from "./middleware.js";
|
||||
|
||||
export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
const logger = childLogger(parentLogger, ['Ingress', 'Listenbrainz']);
|
||||
|
||||
const lzJsonParser = bodyParser.json({
|
||||
type: ['text/*', 'application/json'],
|
||||
});
|
||||
const nonEmptyCheck = nonEmptyBody(logger, 'LZ Endpoint');
|
||||
|
||||
const webhookIngress = new LZEndpointNotifier(logger);
|
||||
app.useAsync(/(\/api\/listenbrainz.*)|(\/1\/submit-listens)/,
|
||||
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)
|
||||
webhookIngress.trackIngress(req, true);
|
||||
if (req.method !== 'POST') {
|
||||
return res.sendStatus(405);
|
||||
}
|
||||
next();
|
||||
},
|
||||
lzJsonParser, nonEmptyCheck, async function (req, res) {
|
||||
webhookIngress.trackIngress(req, false);
|
||||
|
||||
res.sendStatus(200);
|
||||
|
||||
|
||||
const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[];
|
||||
if (sources.length === 0) {
|
||||
logger.warn('Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured');
|
||||
}
|
||||
|
||||
const validSources = sources.filter(x => x.matchRequest(req));
|
||||
if (validSources.length === 0) {
|
||||
const [slug, token] = parseDisplayIdentifiersFromRequest(req);
|
||||
logger.warn(`No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`);
|
||||
}
|
||||
|
||||
const playerState = playStateFromRequest(req.body);
|
||||
|
||||
for (const source of validSources) {
|
||||
await source.handle(playerState);
|
||||
}
|
||||
});
|
||||
app.getAsync('/1/validate-token', async function (req, res) {
|
||||
//https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-validate-token
|
||||
return res.status(200).json({
|
||||
code: 200,
|
||||
message: "Token valid.",
|
||||
valid: true,
|
||||
user_name: "Multi-Scrobbler"
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
sortByNewestPlayDate,
|
||||
sortByOldestPlayDate,
|
||||
} from "../utils.js";
|
||||
import { comparePlayTemporally, temporalAccuracyIsAtLeast, todayAwareFormat } from "../utils/TimeUtils.js";
|
||||
import { comparePlayTemporally, temporalAccuracyIsAtLeast, timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
|
||||
import { getRoot } from '../ioc.js';
|
||||
import { componentFileLogger } from '../common/logging.js';
|
||||
import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';
|
||||
@@ -74,6 +74,9 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
|
||||
supportsUpstreamRecentlyPlayed: boolean = false;
|
||||
supportsUpstreamNowPlaying: boolean = false;
|
||||
supportsManualListening: boolean = false;
|
||||
|
||||
manualListening?: boolean
|
||||
|
||||
emitter: EventEmitter;
|
||||
|
||||
@@ -103,6 +106,13 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
return `${capitalize(this.type)} - ${this.name}`
|
||||
}
|
||||
|
||||
getSystemListeningBehavior = (): boolean | undefined => {
|
||||
if(this.supportsManualListening) {
|
||||
return this.config.options !== undefined && 'systemScrobble' in this.config.options ? this.config.options?.systemScrobble : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => []
|
||||
|
||||
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
@@ -195,10 +205,24 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
return newDiscoveredPlays;
|
||||
}
|
||||
|
||||
protected shouldScrobble = (discoverLocation?: 'backlog' | [key: string]) => {
|
||||
if(this.supportsManualListening && discoverLocation !== 'backlog') {
|
||||
const manualFlag = this.manualListening ?? this.getSystemListeningBehavior() ?? true;
|
||||
if(manualFlag === false) {
|
||||
this.logger.debug(`NOT scrobbling because Should Scrobble is FALSE (${this.manualListening === false ? 'user' : 'system'})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any } = {}) => {
|
||||
|
||||
protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any, discoverLocation?: 'backlog' | [key: string] } = {}) => {
|
||||
|
||||
if(newDiscoveredPlays.length > 0) {
|
||||
if(!this.shouldScrobble(options.discoverLocation)) {
|
||||
return;
|
||||
}
|
||||
newDiscoveredPlays.sort(sortByOldestPlayDate);
|
||||
this.emitter.emit('discoveredToScrobble', {
|
||||
data: newDiscoveredPlays.map(x => this.transformPlay(x, TRANSFORM_HOOK.postCompare)),
|
||||
@@ -230,7 +254,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
} catch (e) {
|
||||
throw new Error('Error occurred while fetching backlogged plays', {cause: e});
|
||||
}
|
||||
const discovered = this.discover(backlogPlays);
|
||||
const discovered = this.discover(backlogPlays, {discoverLocation: 'backlog'});
|
||||
|
||||
const {
|
||||
options: {
|
||||
@@ -440,6 +464,8 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
|
||||
const activeThreshold = this.lastActivityAt.add(checkActiveFor, 's');
|
||||
const inactiveFor = dayjs.duration(Math.abs(activeThreshold.diff(dayjs(), 'millisecond'))).humanize(false);
|
||||
const relativeActivity = dayjs.duration(this.lastActivityAt.diff(dayjs(), 'ms'));
|
||||
const humanRelativeActivity = relativeActivity.asSeconds() > -3 ? '' : ` (${timeToHumanTimestamp(relativeActivity)} ago)`;
|
||||
let friendlyInterval = '';
|
||||
const friendlyLastFormat = todayAwareFormat(this.lastActivityAt);
|
||||
if (activeThreshold.isBefore(dayjs())) {
|
||||
@@ -452,12 +478,12 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
sleepTime = interval + backoff;
|
||||
}
|
||||
if(isDebugMode()) {
|
||||
debugMsgs.push(`Last activity ${friendlyLastFormat} is ${inactiveFor} outside of polling period (last activity + ${checkActiveFor}s)`);
|
||||
debugMsgs.push(`Last activity ${friendlyLastFormat}${humanRelativeActivity} is ${inactiveFor} outside of polling period (last activity + ${checkActiveFor}s)`);
|
||||
} else {
|
||||
debugMsgs.push(`Last activity was at ${friendlyLastFormat}`);
|
||||
debugMsgs.push(`Last activity was at ${friendlyLastFormat}${humanRelativeActivity}`);
|
||||
}
|
||||
} else {
|
||||
debugMsgs.push(`Last activity was at ${friendlyLastFormat}`);
|
||||
debugMsgs.push(`Last activity was at ${friendlyLastFormat}${humanRelativeActivity}`);
|
||||
friendlyInterval = `${formatNumber(sleepTime)}s`;
|
||||
}
|
||||
debugMsgs.push(`Next check in ${friendlyInterval}`);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import { EventEmitter } from "events";
|
||||
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
|
||||
import pEvent from 'p-event';
|
||||
import { PlayObject, URLData } from "../../core/Atomic.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
InternalConfig,
|
||||
PlayerStateData,
|
||||
PlayPlatformId,
|
||||
REPORTED_PLAYER_STATUSES,
|
||||
SINGLE_USER_PLATFORM_ID,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse } from "../common/infrastructure/config/source/azuracast.js";
|
||||
import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.js";
|
||||
|
||||
|
||||
export class AzuracastSource extends MemorySource {
|
||||
|
||||
declare config: AzuracastSourceConfig;
|
||||
|
||||
urlData!: URLData;
|
||||
|
||||
wsNowPlaying: AzuraStationResponse
|
||||
wsCurrenTime: number = 0;
|
||||
client!: WS;
|
||||
|
||||
|
||||
constructor(name: any, config: AzuracastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data = {},
|
||||
options = {},
|
||||
} = config;
|
||||
const {
|
||||
...rest
|
||||
} = data;
|
||||
|
||||
const {
|
||||
data: {
|
||||
monitorWhenListeners,
|
||||
monitorWhenLive
|
||||
} = {}
|
||||
} = config;
|
||||
|
||||
super('azuracast', name, { ...config, options: {systemScrobble: monitorWhenListeners !== undefined || monitorWhenLive === true, ...options}, data: { ...rest } }, internal, emitter);
|
||||
|
||||
|
||||
this.requiresAuth = false;
|
||||
this.canPoll = true;
|
||||
this.supportsManualListening = true;
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
data: {
|
||||
url
|
||||
} = {}
|
||||
} = this.config;
|
||||
if (url === null || url === undefined || url === '') {
|
||||
throw new Error('url must be defined');
|
||||
}
|
||||
this.urlData = normalizeWSAddress(url, { defaultPath: '/api/live/nowplaying/websocket' });
|
||||
const normal = this.urlData.normal;
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
|
||||
if (!normal.includes('ws://') && !normal.includes('wss://')) {
|
||||
throw new Error(`Server URL must be start with with ws:// or wss://`);
|
||||
}
|
||||
this.client = new WS(this.urlData.url.toString(), {
|
||||
automaticOpen: false,
|
||||
retry: {
|
||||
retries: 0
|
||||
}
|
||||
});
|
||||
const wsLogger = childLogger(this.logger, 'WS');
|
||||
this.client.addEventListener('retry', (e) => {
|
||||
wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`, { labels: 'WS' });
|
||||
});
|
||||
this.client.addEventListener('close', (e) => {
|
||||
wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`, { labels: 'WS' });
|
||||
if (e.reason.includes('unauthenticated')) {
|
||||
this.authed = false;
|
||||
}
|
||||
});
|
||||
this.client.addEventListener('open', (e) => {
|
||||
wsLogger.verbose(`Connection was established.`, { labels: 'WS' });
|
||||
// if (this.authed) {
|
||||
// // was a reconnect, try auto authenticating
|
||||
// wsLogger.verbose('Resending auth message after (probably) reconnection...');
|
||||
// this.client.send(JSON.stringify(this.getAuthPayload()));
|
||||
// }
|
||||
});
|
||||
this.client.addEventListener('error', (e) => {
|
||||
if (e.message.includes('Connection failed after')) {
|
||||
this.connectionOK = false;
|
||||
//this.authed = false;
|
||||
}
|
||||
const hint = e.error?.cause?.message ?? undefined;
|
||||
wsLogger.error(new Error(`Communication with server failed${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error }));
|
||||
});
|
||||
|
||||
this.client.addEventListener('message', (e) => {
|
||||
this.parseWSData(getMessageData<any>(e));
|
||||
// if (isAuthenticateResponse(data)) {
|
||||
// wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`);
|
||||
// }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseWSPayload(payload: any, useTime = true) {
|
||||
const jsonData = payload.data;
|
||||
|
||||
if (useTime && 'current_time' in jsonData) {
|
||||
this.wsCurrenTime = jsonData.current_time;
|
||||
}
|
||||
|
||||
this.wsNowPlaying = jsonData.np as AzuraStationResponse;
|
||||
}
|
||||
|
||||
private parseWSData(jsonData: any) {
|
||||
|
||||
if ('connect' in jsonData) {
|
||||
const connectData = jsonData.connect;
|
||||
|
||||
if ('data' in connectData) {
|
||||
// Legacy SSE data
|
||||
connectData.data.forEach(
|
||||
(initialRow) => this.parseWSPayload(initialRow)
|
||||
);
|
||||
} else {
|
||||
// New Centrifugo time format
|
||||
if ('time' in connectData) {
|
||||
this.wsCurrenTime = Math.floor(connectData.time / 1000);
|
||||
}
|
||||
|
||||
// New Centrifugo cached NowPlaying initial push.
|
||||
for (const subName in connectData.subs) {
|
||||
const sub = connectData.subs[subName];
|
||||
if ('publications' in sub && sub.publications.length > 0) {
|
||||
sub.publications.forEach((initialRow) => this.parseWSPayload(initialRow, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ('pub' in jsonData) {
|
||||
this.parseWSPayload(jsonData.pub);
|
||||
}
|
||||
}
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
try {
|
||||
await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname });
|
||||
this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.client.open();
|
||||
const opened = await pEvent(this.client, 'open');
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.client.close();
|
||||
const hint = e.error?.cause?.message ?? undefined;
|
||||
throw new Error(`Could not connect to Azuracast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e });
|
||||
}
|
||||
}
|
||||
|
||||
onPollPostAuthCheck = async (): Promise<boolean> => {
|
||||
this.logger.verbose(`Listening for activity on Station ${this.config.data.station}`);
|
||||
this.client.send(JSON.stringify({
|
||||
subs: {
|
||||
[`station:${this.config.data.station}`]: { "recover": true }
|
||||
}
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO return based on user intervention
|
||||
protected isStationValidListen = () => {
|
||||
if(this.wsNowPlaying === undefined) {
|
||||
this.logger.debug({labels: `Station ${this.config.data.station}`}, `No data returned yet (check station name is correct?)`);
|
||||
return false;
|
||||
}
|
||||
if(!this.wsNowPlaying.is_online && this.config.data.monitorWhenLive) {
|
||||
this.logger.debug({labels: `Station ${this.config.data.station}`}, `Currently offline`);
|
||||
return false;
|
||||
}
|
||||
if(this.manualListening === true) {
|
||||
this.logger.debug({labels: `Station ${this.config.data.station}`}, `Using manual listening status ${this.manualListening}`);
|
||||
return this.manualListening;
|
||||
}
|
||||
if(this.config.data.monitorWhenListeners !== undefined) {
|
||||
if(this.config.data.monitorWhenListeners === true && this.wsNowPlaying.listeners.current === 0) {
|
||||
this.logger.debug({labels: `Station ${this.config.data.station}`}, `No listeners`);
|
||||
return false;
|
||||
}
|
||||
if(typeof this.config.data.monitorWhenListeners === 'number' && this.wsNowPlaying.listeners.current < this.config.data.monitorWhenListeners) {
|
||||
this.logger.debug({labels: `Station ${this.config.data.station}`}, `Requries ${this.config.data.monitorWhenListeners} listeners to be active but currently only ${this.wsNowPlaying.listeners.current}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
if (this.client.readyState !== this.client.OPEN) {
|
||||
throw new Error('WS connection is no longer open.');
|
||||
}
|
||||
|
||||
let play: PlayObject | undefined;
|
||||
const online = this.isStationValidListen();
|
||||
|
||||
if(this.isStationValidListen() && this.wsNowPlaying.now_playing !== undefined) {
|
||||
play = formatPlayObj(this.wsNowPlaying.now_playing);
|
||||
}
|
||||
|
||||
const playerState: PlayerStateData = {
|
||||
platformId: SINGLE_USER_PLATFORM_ID,
|
||||
status: online ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.stopped,
|
||||
play,
|
||||
position: online && play !== undefined ? play.meta.trackProgressPosition : undefined
|
||||
}
|
||||
|
||||
return this.processRecentPlays([playerState]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
|
||||
|
||||
const {
|
||||
song,
|
||||
duration,
|
||||
elapsed,
|
||||
remaining,
|
||||
} = obj;
|
||||
|
||||
const {
|
||||
text,
|
||||
artist,
|
||||
title,
|
||||
album
|
||||
} = song;
|
||||
|
||||
const track: string = title ?? text;
|
||||
|
||||
return {
|
||||
data: {
|
||||
artists: artist !== undefined && artist !== '' ? [artist] : [],
|
||||
album: album !== '' ? album : undefined,
|
||||
track,
|
||||
duration
|
||||
},
|
||||
meta: {
|
||||
trackProgressPosition: elapsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getMessageData = <T>(e: any): T => {
|
||||
return JSON.parse(e.data) as T;
|
||||
}
|
||||
|
||||
const isCloseEvent = (e: Event): e is CloseEvent => {
|
||||
return e.type === 'close';
|
||||
}
|
||||
const isErrorEvent = (e: Event): e is ErrorEvent => {
|
||||
return e.type === 'error';
|
||||
}
|
||||
const isRetryEvent = (e: Event): e is RetryEvent => {
|
||||
return e.type === 'retry';
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import dayjs from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
|
||||
import {
|
||||
ExpressRequest,
|
||||
FormatPlayObjectOptions,
|
||||
InternalConfig,
|
||||
NO_USER,
|
||||
PlayerStateData,
|
||||
REPORTED_PLAYER_STATUSES,
|
||||
ReportedPlayerStatus
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { parseRegexSingleOrFail } from "../utils.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { LastFMEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlfm.js";
|
||||
import { LastfmTrackUpdateRequest, NowPlayingPayload, TrackScrobblePayload } from "lastfm-node-client";
|
||||
import { scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.js";
|
||||
|
||||
const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i);
|
||||
const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i);
|
||||
|
||||
export const authHeaderRegex = new RegExp(/Token (.+)$/i);
|
||||
|
||||
export class EndpointLastfmSource extends MemorySource {
|
||||
|
||||
declare config: LastFMEndpointSourceConfig;
|
||||
|
||||
constructor(name: any, config: LastFMEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super('endpointlfm', name, config, internal, emitter);
|
||||
this.multiPlatform = false;
|
||||
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
|
||||
|
||||
const {
|
||||
data = {},
|
||||
data: {
|
||||
slug,
|
||||
} = {}
|
||||
} = this.config;
|
||||
this.config.data = {
|
||||
...data,
|
||||
slug: slug === null ? undefined : slug,
|
||||
};
|
||||
}
|
||||
|
||||
matchRequest(req: ExpressRequest): boolean {
|
||||
let matchesPath = false;
|
||||
const slug = parseSlugFromRequest(req);
|
||||
if (slug === false) {
|
||||
return false;
|
||||
} else {
|
||||
matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim());
|
||||
}
|
||||
|
||||
return matchesPath;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: LastfmTrackUpdateRequest, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
return scrobblePayloadToPlay(obj);
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
return this.getFlatRecentlyDiscoveredPlays();
|
||||
}
|
||||
|
||||
isValidScrobble = (playObj: PlayObject) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
handle = async (stateData: PlayerStateData) => {
|
||||
|
||||
this.processRecentPlays([stateData]);
|
||||
|
||||
if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) {
|
||||
const discovered = this.discover([stateData.play]);
|
||||
if (discovered.length > 0) {
|
||||
this.scrobble(discovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const playStateFromRequest = (obj: LastfmTrackUpdateRequest): PlayerStateData => {
|
||||
|
||||
const play = scrobblePayloadToPlay(obj);
|
||||
return {
|
||||
platformId: [play.meta.deviceId, NO_USER],
|
||||
play,
|
||||
status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown,
|
||||
timestamp: dayjs()
|
||||
}
|
||||
}
|
||||
|
||||
export const parseSlugFromString = (path: string): string | false | undefined => {
|
||||
const noSlug = parseRegexSingleOrFail(noSlugMatch, path);
|
||||
if (noSlug !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const slugResult = parseRegexSingleOrFail(slugMatch, path);
|
||||
if (slugResult !== undefined) {
|
||||
return slugResult.groups[0];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl);
|
||||
|
||||
export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined] => {
|
||||
const slug = parseSlugFromRequest(req);
|
||||
|
||||
return [slug];
|
||||
}
|
||||
|
||||
export const parseDisplayIdentifiersFromRequest = (req: ExpressRequest): [string] => {
|
||||
const [slug] = parseIdentifiersFromRequest(req);
|
||||
let slugStr = '(no slug)';
|
||||
if (slug === false) {
|
||||
slugStr = '(invalid slug)';
|
||||
} else if (slug !== undefined) {
|
||||
slugStr = slug;
|
||||
}
|
||||
return [slugStr];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import dayjs from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
|
||||
import {
|
||||
ExpressRequest,
|
||||
FormatPlayObjectOptions,
|
||||
InternalConfig,
|
||||
NO_USER,
|
||||
PlayerStateData,
|
||||
REPORTED_PLAYER_STATUSES,
|
||||
ReportedPlayerStatus
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { ListenbrainzEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlz.js";
|
||||
import { ListenbrainzApiClient, ListenPayload, SubmitPayload } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { parseRegexSingleOrFail } from "../utils.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
|
||||
const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?)$/i);
|
||||
const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i);
|
||||
|
||||
export const authHeaderRegex = new RegExp(/Token (.+)$/i);
|
||||
|
||||
export class EndpointListenbrainzSource extends MemorySource {
|
||||
|
||||
declare config: ListenbrainzEndpointSourceConfig;
|
||||
|
||||
constructor(name: any, config: ListenbrainzEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super('endpointlz', name, config, internal, emitter);
|
||||
this.multiPlatform = false;
|
||||
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
|
||||
|
||||
const {
|
||||
data = {},
|
||||
data: {
|
||||
slug,
|
||||
} = {}
|
||||
} = this.config;
|
||||
this.config.data = {
|
||||
token: undefined,
|
||||
...data,
|
||||
slug: slug === null ? undefined : slug,
|
||||
};
|
||||
}
|
||||
|
||||
matchRequest(req: ExpressRequest): boolean {
|
||||
let matchesToken = this.config.data.token === undefined;
|
||||
const reqToken = parseTokenFromRequest(req);
|
||||
if (reqToken === false) {
|
||||
return false;
|
||||
}
|
||||
matchesToken = this.config.data.token === undefined && reqToken === undefined ||
|
||||
(reqToken !== undefined && this.config.data.token !== undefined
|
||||
&& this.config.data.token.toLowerCase().trim() === reqToken.toLowerCase().trim());
|
||||
|
||||
if (!matchesToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let matchesPath = false;
|
||||
const slug = parseSlugFromRequest(req);
|
||||
if (slug === false) {
|
||||
return false;
|
||||
} else {
|
||||
matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim());
|
||||
}
|
||||
|
||||
return matchesToken && matchesPath;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: ListenPayload, options: FormatPlayObjectOptions & {
|
||||
nowPlaying?: boolean
|
||||
} = {}): PlayObject {
|
||||
return ListenbrainzApiClient.listenPayloadToPlay(obj, options.nowPlaying);
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
return this.getFlatRecentlyDiscoveredPlays();
|
||||
}
|
||||
|
||||
isValidScrobble = (playObj: PlayObject) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
handle = async (stateData: PlayerStateData) => {
|
||||
|
||||
this.processRecentPlays([stateData]);
|
||||
|
||||
if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) {
|
||||
const discovered = this.discover([stateData.play]);
|
||||
if (discovered.length > 0) {
|
||||
this.scrobble(discovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => {
|
||||
const {
|
||||
listen_type,
|
||||
payload,
|
||||
} = obj;
|
||||
|
||||
const play = ListenbrainzApiClient.listenPayloadToPlay(payload[0], listen_type === 'playing_now');
|
||||
return {
|
||||
platformId: [play.meta.deviceId, NO_USER],
|
||||
play,
|
||||
status: listenTypeAsPlayerStatus(listen_type),
|
||||
timestamp: dayjs()
|
||||
}
|
||||
}
|
||||
|
||||
export const listenTypeAsPlayerStatus = (event: string): ReportedPlayerStatus => {
|
||||
switch (event) {
|
||||
case 'single':
|
||||
case 'playing_now':
|
||||
return REPORTED_PLAYER_STATUSES.playing;
|
||||
default:
|
||||
return REPORTED_PLAYER_STATUSES.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const parseTokenFromString = (str: string): string | undefined => {
|
||||
const tokenMatch = parseRegexSingleOrFail(authHeaderRegex, str);
|
||||
if(tokenMatch !== undefined) {
|
||||
return tokenMatch.groups[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const parseTokenFromRequest = (req: ExpressRequest): string | false | undefined => {
|
||||
const auth = req.header('Authorization');
|
||||
if(typeof auth === 'string' && auth !== '') {
|
||||
const matchedToken = parseTokenFromString(auth);
|
||||
if(matchedToken === undefined) {
|
||||
return false;
|
||||
}
|
||||
return matchedToken;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const parseSlugFromString = (path: string): string | false | undefined => {
|
||||
const noSlug = parseRegexSingleOrFail(noSlugMatch, path);
|
||||
if (noSlug !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const slugResult = parseRegexSingleOrFail(slugMatch, path);
|
||||
if (slugResult !== undefined) {
|
||||
return slugResult.groups[0];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl);
|
||||
|
||||
export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined, false | string | undefined] => {
|
||||
const slug = parseSlugFromRequest(req);
|
||||
const token = parseTokenFromRequest(req);
|
||||
|
||||
return [slug, token];
|
||||
}
|
||||
|
||||
export const parseDisplayIdentifiersFromRequest = (req: ExpressRequest): [string, string] => {
|
||||
const [slug, token] = parseIdentifiersFromRequest(req);
|
||||
let slugStr = '(no slug)';
|
||||
if (slug === false) {
|
||||
slugStr = '(invalid slug)';
|
||||
} else if (slug !== undefined) {
|
||||
slugStr = slug;
|
||||
}
|
||||
|
||||
let tokenStr = '(no token)';
|
||||
if (token === false) {
|
||||
tokenStr = '(invalid token)';
|
||||
} else if (token !== undefined) {
|
||||
tokenStr = `${token.substring(0,3)}****`
|
||||
}
|
||||
return [slugStr, tokenStr];
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { EventEmitter } from "events";
|
||||
import { PlayObject, URLData } from "../../core/Atomic.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
InternalConfig,
|
||||
PlayerStateData,
|
||||
REPORTED_PLAYER_STATUSES,
|
||||
SINGLE_USER_PLATFORM_ID,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { isPortReachableConnect, normalizeWebAddress } from "../utils/NetworkUtils.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { IcecastMetadata, IcecastSourceConfig } from "../common/infrastructure/config/source/icecast.js";
|
||||
import IcecastMetadataStats from "icecast-metadata-stats";
|
||||
import { parseArtistCredits, parseTrackCredits } from "../utils/StringUtils.js";
|
||||
import { isDebugMode, sleep } from "../utils.js";
|
||||
|
||||
|
||||
export class IcecastSource extends MemorySource {
|
||||
|
||||
declare config: IcecastSourceConfig;
|
||||
|
||||
urlData!: URLData;
|
||||
|
||||
currentMetadata?: IcecastMetadata
|
||||
statsListener?: IcecastMetadataStats
|
||||
|
||||
streamError?: Error;
|
||||
streaming: boolean = false;
|
||||
|
||||
constructor(name: any, config: IcecastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data,
|
||||
options = {},
|
||||
} = config;
|
||||
const {
|
||||
...rest
|
||||
} = data || {};
|
||||
super('icecast', name, { ...config, options: {systemScrobble: false, ...options}, data: { ...rest } }, internal, emitter);
|
||||
|
||||
this.requiresAuth = false;
|
||||
this.canPoll = true;
|
||||
this.supportsManualListening = true;
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
data: {
|
||||
url
|
||||
} = {}
|
||||
} = this.config;
|
||||
if (url === null || url === undefined || url === '') {
|
||||
throw new Error('url must be defined');
|
||||
}
|
||||
this.urlData = normalizeWebAddress(url);
|
||||
const normal = this.urlData.normal;
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
await isPortReachableConnect(this.urlData.port, { host: this.urlData.url.hostname });
|
||||
this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
const hint = e.error?.cause?.message ?? undefined;
|
||||
throw new Error(`Could not connect to Icecast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e });
|
||||
}
|
||||
}
|
||||
|
||||
readIcecastStream = () => {
|
||||
if (this.statsListener !== undefined) {
|
||||
this.statsListener.stop();
|
||||
}
|
||||
|
||||
const {
|
||||
url,
|
||||
...icecastUserOpts
|
||||
} = this.config.data;
|
||||
|
||||
const icecastOpts = {sources: ["icy", 'ogg'], ...icecastUserOpts};
|
||||
|
||||
this.statsListener = new IcecastMetadataStats(this.urlData.url.toString(), {
|
||||
...icecastOpts,
|
||||
onStats: (stats) => {
|
||||
if(isDebugMode()) {
|
||||
this.logger.debug(stats);
|
||||
}
|
||||
this.currentMetadata = stats;
|
||||
if (this.polling === false) {
|
||||
this.statsListener.stop();
|
||||
this.streaming = false;
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
this.streaming = false;
|
||||
this.streamError = e;
|
||||
this.statsListener.stop();
|
||||
},
|
||||
interval: 10
|
||||
});
|
||||
this.statsListener.start();
|
||||
this.streaming = true;
|
||||
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
|
||||
if (this.streaming === false) {
|
||||
this.readIcecastStream();
|
||||
// wait a bit so stream can start and parse metadata
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (this.streamError !== undefined) {
|
||||
if (this.streamError.cause !== undefined && ('res' in (this.streamError.cause as object))) {
|
||||
const res = (this.streamError.cause as Error & { res: Response }).res as Response
|
||||
}
|
||||
throw new Error('Icecast stream produced an error', { cause: this.streamError });
|
||||
}
|
||||
|
||||
if (this.currentMetadata === undefined) {
|
||||
return this.processRecentPlays([]);
|
||||
}
|
||||
|
||||
// if (this.manualListening === false || (this.config.options.scrobbleOnStart === false && this.manualListening === undefined)) {
|
||||
// const playerState: PlayerStateData = {
|
||||
// platformId: SINGLE_USER_PLATFORM_ID,
|
||||
// status: REPORTED_PLAYER_STATUSES.stopped,
|
||||
// play: undefined,
|
||||
// }
|
||||
|
||||
// return this.processRecentPlays([playerState]);
|
||||
// }
|
||||
|
||||
let play: PlayObject | undefined = formatPlayObj(this.currentMetadata);
|
||||
if (play.data.track === undefined) {
|
||||
play = undefined;
|
||||
}
|
||||
|
||||
const playerState: PlayerStateData = {
|
||||
platformId: SINGLE_USER_PLATFORM_ID,
|
||||
status: REPORTED_PLAYER_STATUSES.playing,
|
||||
play
|
||||
}
|
||||
|
||||
return this.processRecentPlays([playerState]);
|
||||
}
|
||||
}
|
||||
|
||||
const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = {}): PlayObject => {
|
||||
|
||||
|
||||
let artist: string,
|
||||
track: string,
|
||||
artists: string[] = [],
|
||||
album: string;
|
||||
|
||||
if (obj.ogg?.TITLE !== undefined) {
|
||||
const {
|
||||
TITLE: oggTitle,
|
||||
ARTIST: oggArtist,
|
||||
ALBUM: oggAlbum
|
||||
} = obj.ogg;
|
||||
|
||||
track = oggTitle;
|
||||
album = oggAlbum;
|
||||
|
||||
const artistCred = parseArtistCredits(oggArtist);
|
||||
if (artistCred !== undefined) {
|
||||
artists.push(artistCred.primary);
|
||||
if (artistCred.secondary !== undefined) {
|
||||
artists = artists.concat(artistCred.secondary);
|
||||
}
|
||||
} else {
|
||||
artists.push(oggArtist);
|
||||
}
|
||||
} else if(obj.icy?.StreamTitle !== undefined) {
|
||||
const value: string = obj.icy.StreamTitle;
|
||||
|
||||
// naive implementation for now
|
||||
const splitStr = value.split('-').map(x => x.trim());
|
||||
|
||||
if (splitStr.length > 1) {
|
||||
artist = splitStr[0];
|
||||
track = value.substring(value.indexOf('-') + 1).trim();
|
||||
|
||||
const artistCred = parseArtistCredits(artist);
|
||||
if (artistCred !== undefined) {
|
||||
artists.push(artistCred.primary);
|
||||
if (artistCred.secondary !== undefined) {
|
||||
artists = artists.concat(artistCred.secondary);
|
||||
}
|
||||
} else {
|
||||
artists.push(artist);
|
||||
}
|
||||
|
||||
const trackCred = parseTrackCredits(track);
|
||||
if (trackCred !== undefined) {
|
||||
track = trackCred.primary;
|
||||
if (trackCred.secondary !== undefined) {
|
||||
artists = artists.concat(trackCred.secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
track,
|
||||
artists
|
||||
},
|
||||
meta: {
|
||||
source: 'icecast'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
doubleReturnNewline,
|
||||
isDebugMode,
|
||||
parseBool,
|
||||
parseDurationFromTimestamp,
|
||||
playObjDataMatch,
|
||||
} from "../utils.js";
|
||||
import { parseDurationFromTimestamp } from '../utils/TimeUtils.js';
|
||||
import {
|
||||
comparePlayTemporally,
|
||||
temporalAccuracyIsAtLeast,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { PlayerStateDataMaybePlay, PlayPlatformId } from "../common/infrastructure/Atomic.js";
|
||||
import { InternalConfig, PlayerStateDataMaybePlay, PlayPlatformId, SourceType } from "../common/infrastructure/Atomic.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
|
||||
import { PositionalPlayerState } from "./PlayerState/PositionalPlayerState.js";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { SourceConfig } from "../common/infrastructure/config/source/sources.js";
|
||||
import EventEmitter from "events";
|
||||
|
||||
export class MemoryPositionalSource extends MemorySource {
|
||||
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super(type, name, config, internal, emitter);
|
||||
this.isPositional = true;
|
||||
}
|
||||
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PositionalPlayerState(logger, id, opts)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import dayjs from "dayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { EventEmitter } from "events";
|
||||
import objectHash from 'object-hash';
|
||||
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
playObjDataMatch,
|
||||
thresholdResultSummary,
|
||||
} from "../utils.js";
|
||||
import { timePassesScrobbleThreshold } from "../utils/TimeUtils.js";
|
||||
import { timePassesScrobbleThreshold, timeToHumanTimestamp } from "../utils/TimeUtils.js";
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
import { AbstractPlayerState, createPlayerOptions, PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
|
||||
import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js";
|
||||
@@ -52,6 +52,8 @@ export default class MemorySource extends AbstractSource {
|
||||
|
||||
scheduler: ToadScheduler = new ToadScheduler();
|
||||
|
||||
protected isPositional: boolean = false;
|
||||
|
||||
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super(type, name, config, internal, emitter);
|
||||
|
||||
@@ -105,7 +107,8 @@ export default class MemorySource extends AbstractSource {
|
||||
// if we haven't already tried to discover any in-progress plays then do it now (and only once)
|
||||
if(!this.playerCleanupDiscoveryAttempt.has(key)) {
|
||||
this.playerCleanupDiscoveryAttempt.set(key, true);
|
||||
const cleanupPlay = player.getPlayedObject();
|
||||
// get play as completed
|
||||
const cleanupPlay = player.getPlayedObject(true);
|
||||
let discoverablePlay: boolean;
|
||||
if(cleanupPlay !== undefined) {
|
||||
const [discoverable, discoverableReason] = this.isListenedPlayDiscoverable(cleanupPlay);
|
||||
@@ -115,11 +118,19 @@ export default class MemorySource extends AbstractSource {
|
||||
}
|
||||
if(discoverable) {
|
||||
discoveredCleanupPlay = cleanupPlay;
|
||||
// we are discovering/scrobbling play
|
||||
// and since player is now stale we should treat this "session" as ended
|
||||
// -- so if user resumes a stale play later its a new session (new real time period of them listening)
|
||||
// basically this is the same as if the player was orphaned and removed
|
||||
//
|
||||
// so we remove listen ranges so the old accumulated listen time can't be used for the "new" listening session
|
||||
player.listenRanges = [];
|
||||
player.currentListenRange = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(deletePlayer) {
|
||||
this.deletePlayer(player.platformIdStr, `Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`);
|
||||
this.deletePlayer(player.platformIdStr, `Removed after being orphaned for ${timeToHumanTimestamp(dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds'))}`);
|
||||
}
|
||||
|
||||
return discoveredCleanupPlay;
|
||||
@@ -140,7 +151,7 @@ export default class MemorySource extends AbstractSource {
|
||||
|
||||
setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => {
|
||||
this.players.set(idStr, this.getNewPlayer(this.logger, id, {
|
||||
...createPlayerOptions(this.config.data as Partial<PollingOptions>),
|
||||
...createPlayerOptions(this.config.data as Partial<PollingOptions>, this.playerSourceOfTruth, this.logger),
|
||||
...opts
|
||||
}));
|
||||
this.playerState.set(idStr, '');
|
||||
@@ -175,7 +186,7 @@ export default class MemorySource extends AbstractSource {
|
||||
return sessions[0];
|
||||
}
|
||||
|
||||
processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[]) => {
|
||||
processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[], reportedTS?: Dayjs) => {
|
||||
|
||||
const {
|
||||
options: {
|
||||
@@ -230,7 +241,7 @@ export default class MemorySource extends AbstractSource {
|
||||
playerState.position = playerState.play.meta?.trackProgressPosition;
|
||||
}
|
||||
|
||||
const [currPlay, prevPlay] = player.update(playerState);
|
||||
const [currPlay, prevPlay] = player.update(playerState, reportedTS);
|
||||
const candidate = prevPlay !== undefined ? prevPlay : currPlay;
|
||||
const playChanged = prevPlay !== undefined;
|
||||
|
||||
@@ -322,7 +333,7 @@ export default class MemorySource extends AbstractSource {
|
||||
for(const player of this.players.values()) {
|
||||
if(player.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing) {
|
||||
const pos = player.getPosition();
|
||||
if(pos !== undefined && player.currentPlay !== undefined) {
|
||||
if(pos !== undefined && this.isPositional && player.currentPlay !== undefined && player.currentPlay.data?.duration !== undefined) {
|
||||
const {
|
||||
data: {
|
||||
duration
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { EventEmitter } from "events";
|
||||
import { PlayObject, URLData } from "../../core/Atomic.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
InternalConfig,
|
||||
PlayerStateData,
|
||||
SINGLE_USER_PLATFORM_ID,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { isPortReachable, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";
|
||||
import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js";
|
||||
import request, { Request, Response } from 'superagent';
|
||||
|
||||
|
||||
export class MusicCastSource extends MemoryPositionalSource {
|
||||
|
||||
declare config: MusicCastSourceConfig;
|
||||
|
||||
urlData!: URLData;
|
||||
|
||||
|
||||
constructor(name: any, config: MusicCastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data = {}
|
||||
} = config;
|
||||
const {
|
||||
...rest
|
||||
} = data;
|
||||
super('musiccast', name, { ...config, data: { ...rest } }, internal, emitter);
|
||||
|
||||
this.requiresAuth = false;
|
||||
this.canPoll = true;
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
data: {
|
||||
url
|
||||
} = {}
|
||||
} = this.config;
|
||||
if (url === null || url === undefined || url === '') {
|
||||
throw new Error('url must be defined');
|
||||
}
|
||||
this.urlData = normalizeWebAddress(url, { defaultPath: '/YamahaExtendedControl/v1' });
|
||||
const normal = this.urlData.normal;
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
await isPortReachableConnect(this.urlData.port, { host: this.urlData.url.hostname });
|
||||
this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`);
|
||||
|
||||
const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString())
|
||||
if (resp.body !== undefined && typeof resp.body === 'object') {
|
||||
const deviceInfo = resp.body as DeviceInfoResponse;
|
||||
this.logger.info(`Found ${deviceInfo.model_name} (${deviceInfo.device_id}) using API v${deviceInfo.api_version}`);
|
||||
} else {
|
||||
this.logger.warn('Could not get device info! Ignoring but probably not good...');
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
const hint = e.error?.cause?.message ?? undefined;
|
||||
throw new Error(`Could not connect to MusicCast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e });
|
||||
}
|
||||
}
|
||||
|
||||
getAnyPlayInfo = async (): Promise<PlayInfoCDResponse | PlayInfoNetResponse | undefined> => {
|
||||
|
||||
try {
|
||||
const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString());
|
||||
if (netResp.body !== undefined && typeof netResp.body === 'object') {
|
||||
const resp = netResp.body as PlayInfoNetResponse
|
||||
if(resp.response_code !== 0) {
|
||||
throw new Error(`netusb source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e}));
|
||||
}
|
||||
|
||||
try {
|
||||
const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString());
|
||||
if (cdResp.body !== undefined && typeof cdResp.body === 'object') {
|
||||
const resp = cdResp.body as PlayInfoCDResponse;
|
||||
if(resp.response_code !== 0) {
|
||||
throw new Error(`cd source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e}));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
|
||||
const statusResp = await request.get(joinedUrl(this.urlData.url, 'main/getStatus').toString());
|
||||
if (statusResp.body == undefined || typeof statusResp.body !== 'object') {
|
||||
this.logger.error({ getStatusResponse: statusResp });
|
||||
throw new Error('Could not determine status of MusicCast device');
|
||||
}
|
||||
if ((statusResp.body as DeviceStatusResponse).power !== 'on') {
|
||||
this.logger.debug('MusicCast device is offline');
|
||||
return this.processRecentPlays([]);
|
||||
}
|
||||
|
||||
const playInfo = await this.getAnyPlayInfo();
|
||||
if(playInfo === undefined) {
|
||||
return this.processRecentPlays([]);
|
||||
}
|
||||
|
||||
const play = formatPlayObj(playInfo);
|
||||
|
||||
|
||||
const playerState: PlayerStateData = {
|
||||
platformId: SINGLE_USER_PLATFORM_ID,
|
||||
status: playbackToReportedStatus(playInfo.playback),
|
||||
play,
|
||||
position: play.meta.trackProgressPosition
|
||||
}
|
||||
|
||||
return this.processRecentPlays([playerState]);
|
||||
}
|
||||
}
|
||||
|
||||
const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
|
||||
|
||||
const {
|
||||
play_time,
|
||||
total_time,
|
||||
artist,
|
||||
album,
|
||||
track,
|
||||
device_status,
|
||||
playback
|
||||
} = obj;
|
||||
|
||||
return {
|
||||
data: {
|
||||
artists: artist !== undefined && artist !== '' ? [artist] : [],
|
||||
album: album !== '' ? album : undefined,
|
||||
track,
|
||||
// we should treat 0 time as the same as not being provided
|
||||
duration: total_time === 0 ? undefined : total_time
|
||||
},
|
||||
meta: {
|
||||
trackProgressPosition: play_time,
|
||||
deviceId: 'input' in obj ? obj.input : 'cd'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
|
||||
import normalizeUrl from 'normalize-url';
|
||||
import pEvent from 'p-event';
|
||||
import { URL } from "url";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { PlayObject, URLData } from "../../core/Atomic.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { sleep } from "../utils.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
|
||||
import { normalizeWSAddress } from "../utils/NetworkUtils.js";
|
||||
|
||||
const CLIENT_STATE = {
|
||||
0: 'connecting',
|
||||
@@ -34,7 +35,7 @@ const CLIENT_STATE = {
|
||||
export class MusikcubeSource extends MemoryPositionalSource {
|
||||
declare config: MusikcubeSourceConfig;
|
||||
|
||||
url: URL;
|
||||
url: URLData;
|
||||
|
||||
|
||||
client!: WS;
|
||||
@@ -56,46 +57,23 @@ export class MusikcubeSource extends MemoryPositionalSource {
|
||||
} = {}
|
||||
} = config;
|
||||
this.deviceId = device_id ?? name;
|
||||
this.url = MusikcubeSource.parseConnectionUrl(url);
|
||||
this.url = normalizeWSAddress(url, {defaultPort: 7905});
|
||||
this.requiresAuth = true;
|
||||
this.canPoll = true;
|
||||
}
|
||||
|
||||
static parseConnectionUrl(valRaw: string) {
|
||||
let val = valRaw.trim();
|
||||
if(!val.match(/^(?:wss?|https?):/i)) {
|
||||
val = `ws://${val}`;
|
||||
}
|
||||
const normal = normalizeUrl(val, {removeTrailingSlash: false})
|
||||
const url = new URL(normal);
|
||||
|
||||
// default WS
|
||||
if (url.protocol === 'https:') {
|
||||
url.protocol = 'wss:';
|
||||
} else if (url.protocol === 'http:') {
|
||||
url.protocol = 'ws:';
|
||||
} else {
|
||||
url.protocol = 'ws:'
|
||||
}
|
||||
|
||||
if (url.port === null || url.port === '') {
|
||||
url.port = '7905';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
data: {
|
||||
url
|
||||
} = {}
|
||||
} = this.config;
|
||||
const normal = this.url.toString();
|
||||
const normal = this.url.normal;
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
|
||||
if (!normal.includes('ws://') && !normal.includes('wss://')) {
|
||||
throw new Error(`Server URL must be start with with ws:// or wss://`);
|
||||
throw new Error(`Server URL must start with ws:// or wss://`);
|
||||
}
|
||||
this.client = new WS(this.url.toString(), {
|
||||
this.client = new WS(this.url.url.toString(), {
|
||||
automaticOpen: false,
|
||||
retry: {
|
||||
retries: 0
|
||||
|
||||
@@ -16,7 +16,7 @@ import { PollingOptions } from "../../common/infrastructure/config/common.js";
|
||||
import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js";
|
||||
import { ListenProgress } from "./ListenProgress.js";
|
||||
import { ListenRange, ListenRangePositional } from "./ListenRange.js";
|
||||
import { todayAwareFormat } from "../../utils/TimeUtils.js";
|
||||
import { timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js";
|
||||
|
||||
export interface PlayerStateIntervals {
|
||||
staleInterval?: number
|
||||
@@ -30,22 +30,38 @@ export interface PlayerStateOptions extends PlayerStateIntervals {
|
||||
|
||||
export const DefaultPlayerStateOptions: PlayerStateOptions = {};
|
||||
|
||||
export const createPlayerOptions = (pollingOpts?: Partial<PollingOptions>, sot: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER): PlayerStateOptions => {
|
||||
export const createPlayerOptions = (pollingOpts?: Partial<PollingOptions>, sot: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER, logger?: Logger): PlayerStateOptions => {
|
||||
const {
|
||||
interval = 30,
|
||||
maxInterval = 60,
|
||||
staleAfter,
|
||||
orphanedAfter
|
||||
} = pollingOpts || {};
|
||||
if(sot === SOURCE_SOT.PLAYER) {
|
||||
return {
|
||||
staleInterval: interval * 3,
|
||||
orphanedInterval: interval * 5
|
||||
}
|
||||
}
|
||||
|
||||
let sa = staleAfter,
|
||||
oa = orphanedAfter;
|
||||
|
||||
// if this player is not the source of truth we don't care about waiting around to see if the state comes back
|
||||
// in fact, we probably want to get rid of it as fast as possible since its superficial and more of an ephemeral "Now Playing" status than something we are actually tracking
|
||||
const staleAfterDefault = sot === SOURCE_SOT.PLAYER ? interval * 3 : interval;
|
||||
const orphanedAfterDefault = sot === SOURCE_SOT.PLAYER ? interval * 5 : maxInterval;
|
||||
|
||||
if(sa === undefined) {
|
||||
sa = staleAfterDefault;
|
||||
}
|
||||
if(oa === undefined) {
|
||||
oa = orphanedAfterDefault;
|
||||
}
|
||||
if(oa < sa) {
|
||||
oa = sa;
|
||||
if(logger !== undefined) {
|
||||
logger.warn(`'orhanedAfter' (${oa}s) was less than 'staleAfter' (${sa}s) which is not allowed! 'orhanedAfter' has been set to equal 'staleAfter'`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
staleInterval: interval,
|
||||
orphanedInterval: maxInterval
|
||||
staleInterval: sa,
|
||||
orphanedInterval: oa
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,18 +102,18 @@ export abstract class AbstractPlayerState {
|
||||
return this.platformId[0] === candidateId[0] && this.platformId[1] === candidateId[1];
|
||||
}
|
||||
|
||||
isUpdateStale() {
|
||||
isUpdateStale(reportedTS?: Dayjs) {
|
||||
if (this.currentPlay !== undefined) {
|
||||
return Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval;
|
||||
return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
checkStale() {
|
||||
const isStale = this.isUpdateStale();
|
||||
checkStale(reportedTS?: Dayjs) {
|
||||
const isStale = this.isUpdateStale(reportedTS);
|
||||
if (isStale && ![CALCULATED_PLAYER_STATUSES.stale, CALCULATED_PLAYER_STATUSES.orphaned].includes(this.calculatedStatus)) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.stale;
|
||||
this.logger.debug(`Stale after no Play updates for ${Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds'))} seconds`);
|
||||
this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.stateIntervalOptions.staleInterval}s)`);
|
||||
// end current listening sessions
|
||||
this.currentListenSessionEnd();
|
||||
}
|
||||
@@ -116,7 +132,7 @@ export abstract class AbstractPlayerState {
|
||||
const isOrphaned = this.isOrphaned();
|
||||
if (isOrphaned && this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.orphaned) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.orphaned;
|
||||
this.logger.debug(`Orphaned after no player updates for ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} minutes`);
|
||||
this.logger.debug(`Orphaned after no Player updates for ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'ms')))} ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} (orhanedAfter ${this.stateIntervalOptions.orphanedInterval}s)`);
|
||||
}
|
||||
return isOrphaned;
|
||||
}
|
||||
@@ -154,7 +170,7 @@ export abstract class AbstractPlayerState {
|
||||
|
||||
protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] {
|
||||
const {play, status, sessionId} = state;
|
||||
this.playLastUpdatedAt = dayjs();
|
||||
this.playLastUpdatedAt = reportedTS ?? dayjs();
|
||||
if (status !== undefined) {
|
||||
this.reportedStatus = status;
|
||||
}
|
||||
@@ -328,7 +344,7 @@ export abstract class AbstractPlayerState {
|
||||
const {play, position} = state;
|
||||
|
||||
this.currentPlay = play;
|
||||
this.playFirstSeenAt = dayjs();
|
||||
this.playFirstSeenAt = reportedTS ?? dayjs();
|
||||
this.listenRanges = [];
|
||||
this.currentListenRange = undefined;
|
||||
|
||||
@@ -351,11 +367,11 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Player Updated At: ${todayAwareFormat(this.stateLastUpdatedAt)} | Play Updated At: ${this.playLastUpdatedAt === undefined ? 'N/A' : todayAwareFormat(this.playLastUpdatedAt)}`);
|
||||
let progress = '';
|
||||
if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentPlay.data.duration !== undefined) {
|
||||
if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) {
|
||||
progress = `${progressBar(this.currentListenRange.end.position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.end.position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `;
|
||||
}
|
||||
let listenedPercent = '';
|
||||
if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) {
|
||||
if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) {
|
||||
listenedPercent = formatNumber((this.getListenDuration() / this.currentPlay.data.duration) * 100, {
|
||||
suffix: '%',
|
||||
toFixed: 0
|
||||
|
||||
@@ -19,13 +19,13 @@ export class GenericPlayerState extends AbstractPlayerState {
|
||||
super(logger, platformId, opts);
|
||||
}
|
||||
|
||||
protected currentListenSessionContinue(position: number = 0, timestamp?: Dayjs) {
|
||||
protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) {
|
||||
if (this.currentListenRange === undefined) {
|
||||
this.logger.debug('Started new Player listen range.');
|
||||
this.currentListenRange = this.newListenRange(this.newListenProgress({timestamp}));
|
||||
this.currentListenRange = this.newListenRange(this.newListenProgress({position, timestamp}));
|
||||
} else {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing;
|
||||
this.currentListenRange.setRangeEnd(this.newListenProgress({timestamp}));
|
||||
this.currentListenRange.setRangeEnd(this.newListenProgress({position, timestamp}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ export class ListenProgressTS implements PlayProgress {
|
||||
|
||||
public timestamp: Dayjs;
|
||||
public positionPercent?: number;
|
||||
public position?: Second;
|
||||
|
||||
constructor(data: Partial<PlayProgress> = {}) {
|
||||
const {timestamp, positionPercent} = data;
|
||||
const {timestamp, positionPercent, position} = data;
|
||||
this.timestamp = timestamp ?? dayjs();
|
||||
this.positionPercent = positionPercent;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
getDuration(end: ListenProgressTS): Second {
|
||||
@@ -27,7 +29,7 @@ export class ListenProgressTS implements PlayProgress {
|
||||
}
|
||||
|
||||
export class ListenProgressPositional extends ListenProgressTS implements PlayProgressPositional {
|
||||
public position: Second;
|
||||
declare public position: Second;
|
||||
|
||||
constructor(data: PlayProgressPositional) {
|
||||
super(data);
|
||||
|
||||
@@ -66,7 +66,7 @@ export class ListenRangeTS extends ListenRange implements ListenRangeData {
|
||||
}
|
||||
|
||||
public getPosition(): Second {
|
||||
return undefined;
|
||||
return this.end.position;
|
||||
}
|
||||
|
||||
public finalize(position?: number) {
|
||||
|
||||
@@ -90,13 +90,25 @@ export class PositionalPlayerState extends AbstractPlayerState {
|
||||
duration,
|
||||
} = {}
|
||||
} = this.currentPlay;
|
||||
if(duration !== undefined && (duration - this.currentListenRange.end.position) < this.gracefulEndBuffer) {
|
||||
if(duration !== undefined && duration !== 0 && (duration - this.currentListenRange.end.position) < this.gracefulEndBuffer) {
|
||||
// likely the track was listened to until it ended
|
||||
// but polling interval or network delays caused MS to not get data on the very end
|
||||
// also...within 3 seconds of ending is close enough to call this complete IMO
|
||||
//
|
||||
// -- STALE is included in this because there are some Sources (jellyfin or plex with 3rd party apps?)
|
||||
// that will created a new "player" if the queue is cleared or stopping the player,
|
||||
// from the user's perspective its all the same but then MS sees it as different
|
||||
// so the player will eventually be pruned but we want to treat the play "going stale" it as if it finished to account for this behavior
|
||||
this.logger.debug(`Listen duration was within ${this.gracefulEndBuffer}s of Play duration, bumping duration to 100% ${this.calculatedStatus === CALCULATED_PLAYER_STATUSES.stale ? 'because stale player probably finished Play before going dark.' : ' because we probably just missed Source reporting 100% before changing Play.'}`)
|
||||
finalPosition = duration;
|
||||
//this.currentListenRange.end.position = duration;
|
||||
|
||||
} else if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.stale && this.currentListenRange.isOverDrifted(this.currentListenRange.end.position)) {
|
||||
// if player uses realtime but source went stale and WAS NOT close to the end its likely the RT is way overdrifted
|
||||
// in which case we definitely do want to use RT as final position
|
||||
// so use last known position before stale instead
|
||||
this.logger.debug(`Player became Stale and realtime position overdrifted before session ended! Using last known position instead of RT position so listen duration stays accurate.`);
|
||||
finalPosition = this.currentListenRange.end.position;
|
||||
}
|
||||
}
|
||||
this.currentListenRange.finalize(finalPosition);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
|
||||
|
||||
const RT_TICK = 500;
|
||||
@@ -9,13 +10,21 @@ export abstract class RealtimePlayer {
|
||||
scheduler: ToadScheduler = new ToadScheduler();
|
||||
|
||||
protected position: number = 0;
|
||||
private clockTS: Dayjs = dayjs();
|
||||
|
||||
protected constructor(/* logger: Logger */) {
|
||||
//this.logger = childLogger(logger, `RT`);
|
||||
const job = new SimpleIntervalJob({
|
||||
milliseconds: RT_TICK,
|
||||
runImmediately: true
|
||||
}, new Task('updatePos', () => this.position += RT_TICK), { id: 'rt' });
|
||||
}, new Task('updatePos', () => {
|
||||
// in production RT_TICK and the diff between now and clockTS should always be the same
|
||||
// but in order to mock for testing (where we manipulate Date now()) the source of truth
|
||||
// needs to come from TS rather than simple TICK increase
|
||||
this.setPosition()
|
||||
//this.position += Math.abs(dayjs().diff(this.clockTS, 'ms')); // RT_TICK
|
||||
|
||||
}), { id: 'rt' });
|
||||
this.scheduler.addSimpleIntervalJob(job);
|
||||
this.scheduler.stop();
|
||||
this.position = 0;
|
||||
@@ -25,6 +34,7 @@ export abstract class RealtimePlayer {
|
||||
if (position !== undefined) {
|
||||
this.position = position;
|
||||
}
|
||||
this.clockTS = dayjs();
|
||||
this.scheduler.startById('rt');
|
||||
}
|
||||
|
||||
@@ -45,8 +55,12 @@ export abstract class RealtimePlayer {
|
||||
return !asSeconds ? this.position : this.position / 1000;
|
||||
}
|
||||
|
||||
public setPosition(time: number) {
|
||||
public setPosition(time?: number) {
|
||||
if(time === undefined) {
|
||||
this.position += Math.abs(dayjs().diff(this.clockTS, 'ms'));
|
||||
}
|
||||
this.position = time;
|
||||
this.clockTS = dayjs();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ import { childLogger, Logger } from '@foxxmd/logging';
|
||||
import EventEmitter from "events";
|
||||
import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } from "../common/infrastructure/Atomic.js";
|
||||
import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js";
|
||||
import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js";
|
||||
import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
|
||||
import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
|
||||
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
|
||||
import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js";
|
||||
import {
|
||||
JellyApiData,
|
||||
JellyApiSourceConfig,
|
||||
@@ -16,6 +19,8 @@ import { KodiData, KodiSourceConfig } from "../common/infrastructure/config/sour
|
||||
import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
|
||||
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
|
||||
import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js";
|
||||
import { MusicCastData, MusicCastSourceConfig } from "../common/infrastructure/config/source/musiccast.js";
|
||||
import { IcecastData, IcecastSourceConfig } from "../common/infrastructure/config/source/icecast.js";
|
||||
import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js";
|
||||
import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js";
|
||||
import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js";
|
||||
@@ -31,8 +36,11 @@ import { WildcardEmitter } from "../common/WildcardEmitter.js";
|
||||
import { parseBool, readJson } from "../utils.js";
|
||||
import { validateJson } from "../utils/ValidationUtils.js";
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
import { AzuracastSource } from "./AzuracastSource.js";
|
||||
import { ChromecastSource } from "./ChromecastSource.js";
|
||||
import DeezerSource from "./DeezerSource.js";
|
||||
import { EndpointListenbrainzSource } from "./EndpointListenbrainzSource.js";
|
||||
import { EndpointLastfmSource } from "./EndpointLastfmSource.js";
|
||||
import JellyfinApiSource from "./JellyfinApiSource.js";
|
||||
import JellyfinSource from "./JellyfinSource.js";
|
||||
import { JRiverSource } from "./JRiverSource.js";
|
||||
@@ -43,6 +51,7 @@ import { MopidySource } from "./MopidySource.js";
|
||||
import { MPDSource } from "./MPDSource.js";
|
||||
import { MPRISSource } from "./MPRISSource.js";
|
||||
import { MusikcubeSource } from "./MusikcubeSource.js";
|
||||
import { MusicCastSource } from "./MusicCastSource.js";
|
||||
import PlexSource from "./PlexSource.js";
|
||||
import SpotifySource from "./SpotifySource.js";
|
||||
import { SubsonicSource } from "./SubsonicSource.js";
|
||||
@@ -53,6 +62,8 @@ import YTMusicSource from "./YTMusicSource.js";
|
||||
import { Definition } from 'ts-json-schema-generator';
|
||||
import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js';
|
||||
import PlexApiSource from './PlexApiSource.js';
|
||||
import { nonEmptyStringOrDefault } from '../../core/StringUtils.js';
|
||||
import { IcecastSource } from './IcecastSource.js';
|
||||
|
||||
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
|
||||
|
||||
@@ -86,14 +97,17 @@ export default class ScrobbleSources {
|
||||
getByNameAndType = (name: string, type: SourceType) => this.sources.find(x => x.name === name && x.type === type)
|
||||
|
||||
async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> {
|
||||
let sources: AbstractSource[]
|
||||
let sources: AbstractSource[] = [];
|
||||
let sourcesReady = true;
|
||||
const messages: string[] = [];
|
||||
|
||||
if(type !== undefined) {
|
||||
sources = this.getByType(type);
|
||||
} else if(name !== undefined) {
|
||||
sources = [this.getByName(name)];
|
||||
const sourceByName = this.getByName(name);
|
||||
if(sourceByName !== undefined) {
|
||||
sources = [sourceByName];
|
||||
}
|
||||
} else {
|
||||
sources = this.sources;
|
||||
}
|
||||
@@ -127,6 +141,15 @@ export default class ScrobbleSources {
|
||||
case 'deezer':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig");
|
||||
break;
|
||||
case 'endpointlz':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig");
|
||||
break;
|
||||
case 'endpointlfm':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("LastFMEndpointSourceConfig");
|
||||
break;
|
||||
case 'icecast':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("IcecastSourceConfig");
|
||||
break;
|
||||
case 'subsonic':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SubSonicSourceConfig");
|
||||
break;
|
||||
@@ -163,17 +186,62 @@ export default class ScrobbleSources {
|
||||
case 'musikcube':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusikcubeSourceConfig");
|
||||
break;
|
||||
case 'musiccast':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusicCastSourceConfig");
|
||||
break;
|
||||
case 'mpd':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig");
|
||||
break;
|
||||
case 'vlc':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("VLCSourceConfig");
|
||||
break;
|
||||
case 'azuracast':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("AzuracastSourceConfig");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this.schemaDefinitions[type];
|
||||
}
|
||||
|
||||
buildSourceDefaults = (fileDefaults: SourceDefaults = {}): SourceDefaults => {
|
||||
const scrobbleDurationEnv = process.env.SOURCE_SCROBBLE_DURATION;
|
||||
const scrobblePercentEnv = process.env.SOURCE_SCROBBLE_PERCENT;
|
||||
|
||||
const buildDefaults = {...fileDefaults};
|
||||
|
||||
if(nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined || nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) {
|
||||
const {
|
||||
scrobbleThresholds: {
|
||||
duration,
|
||||
percent
|
||||
} = {},
|
||||
scrobbleThresholds = {}
|
||||
} = fileDefaults;
|
||||
buildDefaults.scrobbleThresholds = {...scrobbleThresholds};
|
||||
|
||||
if(duration === undefined && nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined) {
|
||||
const envDur = Number.parseInt(scrobbleDurationEnv);
|
||||
if(Number.isNaN(envDur)) {
|
||||
this.logger.warn(`Ignoring value '${scrobbleDurationEnv}' for env SOURCE_SCROBBLE_DURATION because it is not a number`);
|
||||
} else {
|
||||
buildDefaults.scrobbleThresholds.duration = envDur;
|
||||
this.logger.verbose(`Set default scrobble threshold duration to '${scrobbleDurationEnv}' based on env SOURCE_SCROBBLE_DURATION`);
|
||||
}
|
||||
}
|
||||
if(percent === undefined && nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) {
|
||||
const envPercent = Number.parseInt(scrobblePercentEnv);
|
||||
if(Number.isNaN(envPercent)) {
|
||||
this.logger.warn(`Ignoring value '${scrobblePercentEnv}' for env SOURCE_SCROBBLE_PERCENT because it is not a number`);
|
||||
} else {
|
||||
buildDefaults.scrobbleThresholds.percent = envPercent;
|
||||
this.logger.verbose(`Set default scrobble threshold percent to '${scrobblePercentEnv}' based on env SOURCE_SCROBBLE_PERCENT`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaults;
|
||||
}
|
||||
|
||||
buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => {
|
||||
const configs: ParsedConfig[] = additionalConfigs;
|
||||
|
||||
@@ -193,7 +261,7 @@ export default class ScrobbleSources {
|
||||
sources: mainConfigSourcesConfigs = [],
|
||||
sourceDefaults: sd = {},
|
||||
} = aioConfig;
|
||||
sourceDefaults = sd;
|
||||
sourceDefaults = this.buildSourceDefaults(sd);
|
||||
for (const [index, c] of mainConfigSourcesConfigs.entries()) {
|
||||
const {name = 'unnamed'} = c;
|
||||
if(!isSourceType(c.type.toLocaleLowerCase())) {
|
||||
@@ -221,6 +289,8 @@ export default class ScrobbleSources {
|
||||
configureAs: 'source' // override user value
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sourceDefaults = this.buildSourceDefaults();
|
||||
}
|
||||
|
||||
for (const sourceType of sourceTypes) {
|
||||
@@ -374,6 +444,58 @@ export default class ScrobbleSources {
|
||||
// sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior
|
||||
defaultConfigureAs = 'client';
|
||||
break;
|
||||
case 'endpointlz':
|
||||
const lzShouldUse = parseBool(process.env.LZENDPOINT_ENABLE);
|
||||
const lze = {
|
||||
slug: process.env.LZE_SLUG,
|
||||
token: process.env.LZE_TOKEN
|
||||
}
|
||||
if (!Object.values(lze).every(x => x === undefined) || lzShouldUse) {
|
||||
configs.push({
|
||||
type: 'endpointlz',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: lze as ListenbrainzEndpointData
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'endpointlfm':
|
||||
const lfmShouldUse = parseBool(process.env.LFMENDPOINT_ENABLE);
|
||||
const lfme = {
|
||||
slug: process.env.LFM_SLUG,
|
||||
}
|
||||
if (!Object.values(lfme).every(x => x === undefined) || lfmShouldUse) {
|
||||
configs.push({
|
||||
type: 'endpointlfm',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: lfme as LastFMEndpointData
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'icecast':
|
||||
const icecast = {
|
||||
url: process.env.ICECAST_URL,
|
||||
scrobbleOnStart: process.env.ICECAST_SCROBBLE_START,
|
||||
}
|
||||
if (!Object.values(icecast).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'icecast',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: icecast as IcecastData,
|
||||
options: {
|
||||
systemScrobble: parseBool(icecast.scrobbleOnStart)
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'jriver':
|
||||
const jr = {
|
||||
url: process.env.JRIVER_URL,
|
||||
@@ -452,6 +574,21 @@ export default class ScrobbleSources {
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'musiccast':
|
||||
const musecase = {
|
||||
url: process.env.MCAST_URL,
|
||||
}
|
||||
if (!Object.values(musecase).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'musiccast',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: musecase as MusicCastData
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'musikcube':
|
||||
const mc = {
|
||||
url: process.env.MC_URL,
|
||||
@@ -501,6 +638,25 @@ export default class ScrobbleSources {
|
||||
data: ytm as YTMusicData
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'azuracast':
|
||||
const azura = {
|
||||
station: process.env.AZURA_STATION,
|
||||
url: process.env.AZURA_URL,
|
||||
monitorWhenListeners: process.env.AZURA_LISTENERS_NUM,
|
||||
monitorWhenLive: process.env.AZURA_LIVE,
|
||||
apiKey: process.env.AZURA_KEY
|
||||
}
|
||||
if (!Object.values(azura).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'azuracast',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: azura as unknown as AzuracastData
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -660,6 +816,15 @@ export default class ScrobbleSources {
|
||||
case 'listenbrainz':
|
||||
newSource = await new ListenbrainzSource(name, compositeConfig as ListenBrainzSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'endpointlz':
|
||||
newSource = await new EndpointListenbrainzSource(name, compositeConfig as ListenbrainzEndpointSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'endpointlfm':
|
||||
newSource = await new EndpointLastfmSource(name, compositeConfig as LastFMEndpointSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'icecast':
|
||||
newSource = await new IcecastSource(name, compositeConfig as IcecastSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'jriver':
|
||||
newSource = await new JRiverSource(name, compositeConfig as JRiverSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
@@ -675,12 +840,18 @@ export default class ScrobbleSources {
|
||||
case 'musikcube':
|
||||
newSource = await new MusikcubeSource(name, compositeConfig as MusikcubeSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'musiccast':
|
||||
newSource = await new MusicCastSource(name, compositeConfig as MusicCastSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'mpd':
|
||||
newSource = await new MPDSource(name, compositeConfig as MPDSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'vlc':
|
||||
newSource = await new VLCSource(name, compositeConfig as VLCSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'azuracast':
|
||||
newSource = await new AzuracastSource(name, compositeConfig as AzuracastSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { joinedUrl } from "../utils/NetworkUtils.js";
|
||||
import { FixedSizeList } from "fixed-size-list";
|
||||
import { todayAwareFormat } from "../utils/TimeUtils.js";
|
||||
import { RestType } from "ts-json-schema-generator";
|
||||
import { parseArtistCredits, parseCredits } from "../utils/StringUtils.js";
|
||||
|
||||
export interface HistoryIngressResult {
|
||||
plays: PlayObject[],
|
||||
@@ -348,6 +349,25 @@ Redirect URI : ${this.redirectUri}`);
|
||||
} else if(authorData !== undefined) {
|
||||
artists = authorData.map(x => x.name) as string[];
|
||||
}
|
||||
if(artists.length === 0 && obj.flex_columns.at(1)?.title?.text !== undefined) {
|
||||
// if YTM doesn't have an endpoint (page) for an artist (combined) then YouTube.js doesn't include it
|
||||
// in the music shelf object created from parsing data
|
||||
// BUT YouTube.js does expose the raw data so we can try to recover artists from plain text
|
||||
// https://github.com/LuanRT/YouTube.js/issues/381
|
||||
const credits = parseArtistCredits(obj.flex_columns.at(1)?.title?.text);
|
||||
if(credits !== undefined) {
|
||||
// try to be clever
|
||||
artists.push(credits.primary);
|
||||
if(credits.secondary !== undefined) {
|
||||
const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== '');
|
||||
if(nonEmptyArtists.length > 0) {
|
||||
artists = [...artists, ...nonEmptyArtists];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
artists = [obj.flex_columns.at(1)?.title?.text];
|
||||
}
|
||||
}
|
||||
|
||||
let albumArtists: string[] = [];
|
||||
if(artistsData !== undefined && authorData !== undefined) {
|
||||
@@ -356,6 +376,9 @@ Redirect URI : ${this.redirectUri}`);
|
||||
if(albumData !== undefined) {
|
||||
album = albumData.name;
|
||||
}
|
||||
if(album === undefined && obj.flex_columns.at(2)?.title?.text !== undefined) {
|
||||
album = obj.flex_columns.at(2)?.title?.text;
|
||||
}
|
||||
if(dur!== undefined) {
|
||||
const durObj = dayjs.duration(dur.seconds, 's')
|
||||
duration = durObj.asSeconds();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { Request } from "express";
|
||||
import { parseIdentifiersFromRequest } from "../EndpointLastfmSource.js";
|
||||
import { IngressNotifier } from "./IngressNotifier.js";
|
||||
import { LastfmTrackUpdateRequest } from "lastfm-node-client";
|
||||
|
||||
export class LFMEndpointNotifier extends IngressNotifier {
|
||||
|
||||
constructor(logger: Logger) {
|
||||
super('Lastfm Endpoint', logger);
|
||||
}
|
||||
|
||||
seenSlugs: Record<string, boolean> = {};
|
||||
notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] {
|
||||
|
||||
if(!isRaw) {
|
||||
|
||||
const [slug] = parseIdentifiersFromRequest(req);
|
||||
if(slug === false) {
|
||||
return [false, `Request URL was not valid: ${req.baseUrl}`];
|
||||
}
|
||||
const slugStr = slug ?? '(no slug)';
|
||||
|
||||
const identifier = `${slugStr}`;
|
||||
|
||||
if(this.seenSlugs[identifier] === undefined) {
|
||||
this.seenSlugs[identifier] = true;
|
||||
return [true, `Received a request to endpoint with -- Slug: ${slugStr} -- for the first time.`];
|
||||
}
|
||||
}
|
||||
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
|
||||
if(req.method !== 'POST') {
|
||||
return `Expected POST request (track.scrobble payload) but received ${req.method}`;
|
||||
}
|
||||
if(!isRaw) {
|
||||
if(!('method' in req.body)) {
|
||||
return `Body is missing 'method' param`
|
||||
}
|
||||
const method = (req.body as LastfmTrackUpdateRequest).method;
|
||||
if(!['track.updateNowPlaying','track.scrobble'].includes(method)) {
|
||||
return `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'`
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { Request } from "express";
|
||||
import { EndpointListenbrainzSource, parseIdentifiersFromRequest } from "../EndpointListenbrainzSource.js";
|
||||
import { IngressNotifier } from "./IngressNotifier.js";
|
||||
|
||||
export class LZEndpointNotifier extends IngressNotifier {
|
||||
|
||||
constructor(logger: Logger) {
|
||||
super('Listenbrainz Endpoint', logger);
|
||||
}
|
||||
|
||||
seenSlugs: Record<string, boolean> = {};
|
||||
notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] {
|
||||
|
||||
if(!isRaw) {
|
||||
|
||||
const [slug, token] = parseIdentifiersFromRequest(req);
|
||||
if(slug === false) {
|
||||
return [false, `Request URL was not valid: ${req.baseUrl}`];
|
||||
}
|
||||
const slugStr = slug ?? '(no slug)';
|
||||
|
||||
if(token === false) {
|
||||
return [false, `Request URL was valid and 'Authorization' header was present but invalid. Authorization header should be 'Token tokenValue' but was '${req.header('Authorization')}'`];
|
||||
}
|
||||
const tokenStr = token ?? '(no token)';
|
||||
const redactedToken = token !== undefined ? `${(token as string).substring(0,3)}****` : '(no token)';
|
||||
|
||||
const identifier = `${slugStr}-${tokenStr}`;
|
||||
|
||||
if(this.seenSlugs[identifier] === undefined) {
|
||||
this.seenSlugs[identifier] = true;
|
||||
return [true, `Received a well formed request to endpoint with -- Slug: ${slugStr} | Token: ${redactedToken} -- for the first time.`];
|
||||
}
|
||||
}
|
||||
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
|
||||
if(req.method !== 'POST') {
|
||||
return `Expected POST request (submit-listen payload) but received ${req.method}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,30 @@ describe('Play Transforms', function () {
|
||||
expect(transformed.data.track).equal('My cool thing track');
|
||||
});
|
||||
|
||||
|
||||
it('Transforms using parsed regex to get primary artist from delimited artist string', function () {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
artists: [
|
||||
{
|
||||
search: '/(.*?)(\\s*\\/\\s*)(.*$)/i',
|
||||
replace: '$1'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component.buildTransformRules();
|
||||
|
||||
const play = generatePlay({artists: ['My Artist One / My Artist Two / Another Guy']});
|
||||
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
|
||||
expect(transformed.data.artists).length(1)
|
||||
expect(transformed.data.artists[0]).equal('My Artist One');
|
||||
});
|
||||
|
||||
it('Removes title when transform replaces with empty string', function () {
|
||||
component.config = {
|
||||
options: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { after, before, describe, it } from 'mocha';
|
||||
import { after, before, afterEach, describe, it } from 'mocha';
|
||||
import chai, { assert, expect } from 'chai';
|
||||
import asPromised from 'chai-as-promised';
|
||||
import withLocalTmpDir from 'with-local-tmp-dir';
|
||||
@@ -11,6 +11,7 @@ import EventEmitter from "events";
|
||||
import {loggerTest, loggerDebug} from '@foxxmd/logging';
|
||||
import { clientTypes, SourceType, sourceTypes } from '../../common/infrastructure/Atomic.js';
|
||||
import { Notifiers } from '../../notifier/Notifiers.js';
|
||||
import { difference, intersect } from '../../utils.js';
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
@@ -97,3 +98,52 @@ describe('Sample Configs', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global ENVs with Config', function () {
|
||||
|
||||
let baseEnvKeys: string[] = [];
|
||||
|
||||
before(function() {
|
||||
baseEnvKeys = Array.from(Object.keys(process.env));
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
const modified = difference(Array.from(Object.keys(process.env)), baseEnvKeys);
|
||||
for(const key of modified) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
it('Parses default scrobble duration', async function () {
|
||||
process.env.SOURCE_SCROBBLE_DURATION = '20';
|
||||
process.env.MPRIS_ENABLE = 'true';
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const sources = new ScrobbleSources(emitter, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
configDir: process.cwd(),
|
||||
version: 'test'
|
||||
}, loggerTest);
|
||||
await sources.buildSourcesFromConfig();
|
||||
|
||||
expect(sources.sources).length(1);
|
||||
expect(sources.sources[0].config?.options?.scrobbleThresholds?.duration).to.eq(20);
|
||||
});
|
||||
|
||||
it('Parses default scrobble precentage', async function () {
|
||||
process.env.SOURCE_SCROBBLE_PERCENT = '20';
|
||||
process.env.MPRIS_ENABLE = 'true';
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const sources = new ScrobbleSources(emitter, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
configDir: process.cwd(),
|
||||
version: 'test'
|
||||
}, loggerTest);
|
||||
await sources.buildSourcesFromConfig();
|
||||
|
||||
expect(sources.sources).length(1);
|
||||
expect(sources.sources[0].config?.options?.scrobbleThresholds?.percent).to.eq(20);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { loggerTest, loggerDebug } from "@foxxmd/logging";
|
||||
import { assert, expect } from 'chai';
|
||||
import EventEmitter from "events";
|
||||
import dayjs from "dayjs";
|
||||
import { describe, it, before, after } from 'mocha';
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { withRequestInterception } from "../utils/networking.js";
|
||||
import { MusicCastData } from "../../common/infrastructure/config/source/musiccast.js";
|
||||
import { MusicCastSource } from "../../sources/MusicCastSource.js";
|
||||
import netInterceptor from "@gr2m/net-interceptor";
|
||||
import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js";
|
||||
|
||||
const TEST_IP = '192.168.10.101';
|
||||
|
||||
const createSource = (data: MusicCastData = { url: TEST_IP }): MusicCastSource => {
|
||||
const source = new MusicCastSource('Test', {
|
||||
data,
|
||||
options: {}
|
||||
}, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter());
|
||||
return source;
|
||||
}
|
||||
|
||||
describe('MusicCast Startup', function () {
|
||||
|
||||
after(() => {
|
||||
netInterceptor.stop();
|
||||
});
|
||||
it('tests for device info correctly', withRequestInterception(
|
||||
[
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/system/getDeviceInfo`, () => {
|
||||
return HttpResponse.json({
|
||||
model_name: 'test',
|
||||
device_id: 'testid',
|
||||
system_version: '1234',
|
||||
version: '1test',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
})
|
||||
],
|
||||
async function () {
|
||||
const source = createSource();
|
||||
|
||||
await source.buildInitData();
|
||||
|
||||
netInterceptor.start();
|
||||
|
||||
netInterceptor.on("connection", (socket) => {
|
||||
socket.write("Hello there.");
|
||||
netInterceptor.stop();
|
||||
});
|
||||
await source.checkConnection();
|
||||
expect(source.connectionOK).to.be.true;
|
||||
}
|
||||
));
|
||||
});
|
||||
|
||||
describe('MusicCast State Handling', function() {
|
||||
|
||||
it('Handles standby mode', withRequestInterception(
|
||||
[
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
|
||||
return HttpResponse.json({
|
||||
power: 'standby',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
})
|
||||
],
|
||||
async function () {
|
||||
const source = createSource();
|
||||
|
||||
await source.buildInitData();
|
||||
source.connectionOK = true;
|
||||
await source.getRecentlyPlayed();
|
||||
expect(source.players.size).to.eq(0);
|
||||
}
|
||||
));
|
||||
|
||||
it('Handles valid netusb device playing state', withRequestInterception(
|
||||
[
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
|
||||
return HttpResponse.json({
|
||||
power: 'on',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
}),
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => {
|
||||
return HttpResponse.json({
|
||||
device_status: 'ready',
|
||||
playback: 'play',
|
||||
play_time: 10,
|
||||
total_time: 60,
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
track: 'Cool Track',
|
||||
input: 'av1',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
}),
|
||||
],
|
||||
async function () {
|
||||
const source = createSource();
|
||||
|
||||
await source.buildInitData();
|
||||
source.connectionOK = true;
|
||||
await source.getRecentlyPlayed();
|
||||
expect(source.players.size).to.eq(1);
|
||||
const playerState = source.players.get(source.players.keys().next().value).getApiState();
|
||||
expect(playerState.play.data.album).to.eq('Test Album');
|
||||
expect(playerState.play.data.track).to.eq('Cool Track');
|
||||
expect(playerState.play.data.duration).to.eq(60);
|
||||
expect(playerState.play.meta.trackProgressPosition).to.eq(10);
|
||||
expect(playerState.play.meta.deviceId).to.eq('av1');
|
||||
expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing);
|
||||
}
|
||||
));
|
||||
|
||||
it('Handles valid cd device playing state', withRequestInterception(
|
||||
[
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
|
||||
return HttpResponse.json({
|
||||
power: 'on',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
}),
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => {
|
||||
return HttpResponse.json({
|
||||
response_code: 100
|
||||
}, { status: 200 });
|
||||
}),
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => {
|
||||
return HttpResponse.json({
|
||||
device_status: 'ready',
|
||||
playback: 'play',
|
||||
play_time: 10,
|
||||
total_time: 60,
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
track: 'Cool Track',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
}),
|
||||
],
|
||||
async function () {
|
||||
const source = createSource();
|
||||
|
||||
await source.buildInitData();
|
||||
source.connectionOK = true;
|
||||
await source.getRecentlyPlayed();
|
||||
expect(source.players.size).to.eq(1);
|
||||
const playerState = source.players.get(source.players.keys().next().value).getApiState();
|
||||
expect(playerState.play.data.album).to.eq('Test Album');
|
||||
expect(playerState.play.data.track).to.eq('Cool Track');
|
||||
expect(playerState.play.data.duration).to.eq(60);
|
||||
expect(playerState.play.meta.trackProgressPosition).to.eq(10);
|
||||
expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing);
|
||||
}
|
||||
));
|
||||
|
||||
it('Handles non 200 status from getPlayInfo', withRequestInterception(
|
||||
[
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
|
||||
return HttpResponse.json({
|
||||
power: 'on',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
}),
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => {
|
||||
return HttpResponse.json({
|
||||
response_code: 100
|
||||
}, { status: 500 });
|
||||
}),
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => {
|
||||
return HttpResponse.json({
|
||||
device_status: 'ready',
|
||||
playback: 'play',
|
||||
play_time: 10,
|
||||
total_time: 60,
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
track: 'Cool Track',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
}),
|
||||
],
|
||||
async function () {
|
||||
const source = createSource();
|
||||
|
||||
await source.buildInitData();
|
||||
source.connectionOK = true;
|
||||
await source.getRecentlyPlayed();
|
||||
expect(source.players.size).to.eq(1);
|
||||
}
|
||||
));
|
||||
|
||||
it('Handles stopped player', withRequestInterception(
|
||||
[
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
|
||||
return HttpResponse.json({
|
||||
power: 'on',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
}),
|
||||
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => {
|
||||
return HttpResponse.json({
|
||||
device_status: 'ready',
|
||||
playback: 'stop',
|
||||
play_time: 10,
|
||||
total_time: 60,
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
track: 'Cool Track',
|
||||
input: 'av1',
|
||||
response_code: 0
|
||||
}, { status: 200 });
|
||||
}),
|
||||
],
|
||||
async function () {
|
||||
const source = createSource();
|
||||
|
||||
await source.buildInitData();
|
||||
source.connectionOK = true;
|
||||
await source.getRecentlyPlayed();
|
||||
expect(source.players.size).to.eq(1);
|
||||
const playerState = source.players.get(source.players.keys().next().value).getApiState();
|
||||
expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.stopped);
|
||||
}
|
||||
));
|
||||
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { loggerTest } from "@foxxmd/logging";
|
||||
import { assert } from 'chai';
|
||||
import { assert, expect } from 'chai';
|
||||
import clone from "clone";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { describe, it } from 'mocha';
|
||||
@@ -26,7 +26,7 @@ const testState = (data: Omit<PlayerStateDataMaybePlay, 'platformId'>): PlayerSt
|
||||
|
||||
class TestPositionalPlayerState extends PositionalPlayerState {
|
||||
protected newListenRange(start?: ListenProgressPositional, end?: ListenProgressPositional, options: object = {}): ListenRangePositional {
|
||||
const range = super.newListenRange(start, end, {rtImmediate: false, ...options});
|
||||
const range = super.newListenRange(start, end, {allowedDrift: this.allowedDrift, rtImmediate: false, rtTruth: this.rtTruth, ...options});
|
||||
return range;
|
||||
}
|
||||
public testSessionRepeat(position: number, reportedTS?: Dayjs) {
|
||||
@@ -151,6 +151,29 @@ describe('Player status', function () {
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus);
|
||||
});
|
||||
|
||||
it('Uses last known position for final range when cleaning up stale player', function () {
|
||||
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER], {staleInterval: 20, rtTruth: true});
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.meta.trackProgressPosition = 3;
|
||||
|
||||
player.update(testState({play: positioned, position: 3}));
|
||||
|
||||
player.currentListenRange.rtPlayer.setPosition(13000);
|
||||
player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
|
||||
|
||||
player.currentListenRange.rtPlayer.setPosition(23000);
|
||||
player.update(testState({play: positioned, position: 23}), dayjs().add(20, 'seconds'));
|
||||
|
||||
const staleDate = dayjs().add(41, 'seconds')
|
||||
player.currentListenRange.rtPlayer.setPosition(44000);
|
||||
expect(player.currentListenRange.isOverDrifted(23)).to.be.true;
|
||||
|
||||
expect(player.checkStale(staleDate)).to.be.true;
|
||||
expect(player.listenRanges[player.listenRanges.length - 1].end.position).to.eq(23);
|
||||
expect(player.getListenDuration()).to.eq(20);
|
||||
});
|
||||
|
||||
// TODO playback position reported and conflicts with player reported status
|
||||
});
|
||||
});
|
||||
@@ -207,7 +230,7 @@ describe('Player listen ranges', function () {
|
||||
|
||||
describe('When source does provide playback position', function () {
|
||||
|
||||
it('Duration is position based', function () {
|
||||
it('Listened duration is position based', function () {
|
||||
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
@@ -220,6 +243,43 @@ describe('Player listen ranges', function () {
|
||||
assert.equal(player.getListenDuration(), 7);
|
||||
});
|
||||
|
||||
it('Listened duration is track duration invariant', function () {
|
||||
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.data.duration = 0;
|
||||
|
||||
player.update(testState({play: positioned, position: 3}));
|
||||
|
||||
player.currentListenRange.rtPlayer.setPosition(10000);
|
||||
player.update(testState({play: positioned, position: 10}), dayjs().add(10, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 7);
|
||||
});
|
||||
|
||||
it('Listened for is track duration invariant', function () {
|
||||
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.data.duration = 0;
|
||||
|
||||
player.update(testState({play: positioned, position: 3, status: REPORTED_PLAYER_STATUSES.playing}));
|
||||
|
||||
player.currentListenRange.rtPlayer.setPosition(10000);
|
||||
player.update(testState({play: positioned, position: 10, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
|
||||
|
||||
player.currentListenRange.rtPlayer.setPosition(20000);
|
||||
player.update(testState({play: positioned, position: 20, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds'));
|
||||
|
||||
const otherPlay = clone(positioned);
|
||||
otherPlay.data.track = "A New Track";
|
||||
player.currentListenRange.rtPlayer.setPosition(30000);
|
||||
const [currPlay, prevPlay] = player.update(testState({play: otherPlay, position: 2, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(30, 'seconds'));
|
||||
|
||||
assert.isDefined(prevPlay);
|
||||
assert.equal(prevPlay.data.listenedFor, 17);
|
||||
});
|
||||
|
||||
it('Range ends if position over drifts', function () {
|
||||
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import AbstractSource from "../../sources/AbstractSource.js";
|
||||
import { MemoryPositionalSource } from "../../sources/MemoryPositionalSource.js";
|
||||
import MemorySource from "../../sources/MemorySource.js";
|
||||
|
||||
export class TestSource extends AbstractSource {
|
||||
handle(plays: PlayObject[]) {
|
||||
this.scrobble(plays);
|
||||
}
|
||||
}
|
||||
|
||||
export class TestMemorySource extends MemoryPositionalSource {
|
||||
|
||||
}
|
||||
|
||||
export class TestMemoryPositionalSource extends MemoryPositionalSource {
|
||||
|
||||
}
|
||||
@@ -6,10 +6,17 @@ import { after, before, describe, it } from 'mocha';
|
||||
import pEvent from "p-event";
|
||||
import clone from 'clone';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { generatePlay } from "../utils/PlayTestUtils.js";
|
||||
import { TestSource } from "./TestSource.js";
|
||||
import { generatePlay, generatePlayerStateData } from "../utils/PlayTestUtils.js";
|
||||
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
|
||||
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json';
|
||||
import SpotifySource from "../../sources/SpotifySource.js";
|
||||
import MockDate from 'mockdate';
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js";
|
||||
import { SourceConfig } from "../../common/infrastructure/config/source/sources.js";
|
||||
import MemorySource from "../../sources/MemorySource.js";
|
||||
import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js";
|
||||
import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js";
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
@@ -20,6 +27,20 @@ const generateSource = () => {
|
||||
}
|
||||
let source: TestSource = generateSource();
|
||||
|
||||
const generateMemorySource = (config: SourceConfig = {}) => {
|
||||
const s = new TestMemorySource('spotify', 'test', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
|
||||
s.buildTransformRules();
|
||||
s.scheduler.stop();
|
||||
return s;
|
||||
}
|
||||
|
||||
const generateMemoryPositionalSource = (config: SourceConfig = {}) => {
|
||||
const s = new TestMemoryPositionalSource('spotify', 'test', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
|
||||
s.buildTransformRules();
|
||||
s.scheduler.stop();
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('Sources use transform plays correctly', function () {
|
||||
|
||||
beforeEach(function() {
|
||||
@@ -159,4 +180,260 @@ describe('Sources correctly parse incoming payloads', function () {
|
||||
expect(identicalArtistsPlay.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']);
|
||||
expect(identicalArtistsPlay.data.albumArtists).to.be.empty;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Player Cleanup', function () {
|
||||
|
||||
this.afterEach(() => {
|
||||
MockDate.reset();
|
||||
});
|
||||
|
||||
const cleanedUpDuration = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
|
||||
|
||||
let position = 0;
|
||||
let timeSince = 0;
|
||||
|
||||
// simulate polling playing source for 30 seconds, 10 second interval
|
||||
for(let i = 0; i < 3; i++) {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
// simulate polling another 20 seconds without any updates from the Source
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(1);
|
||||
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
|
||||
}
|
||||
|
||||
it('Discovers cleaned up Play with correct duration (Non Positional Source)', function () {
|
||||
cleanedUpDuration(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Discovers cleaned up Play with correct duration (Positional Source)', function () {
|
||||
cleanedUpDuration(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const noScrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
|
||||
|
||||
let position = 0;
|
||||
let timeSince = 0;
|
||||
|
||||
// simulate polling playing source for 30 seconds, 10 second interval
|
||||
for(let i = 0; i < 3; i++) {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
// simulate polling another 20 seconds without any updates from the Source
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 2;
|
||||
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(1);
|
||||
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
|
||||
|
||||
timeSince += 10;
|
||||
|
||||
position -= 9;
|
||||
// simulate polling another 20 seconds with active source again
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
// new Play
|
||||
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
// should not return play because it has only been played for ~20 seconds, less than 50% of duration
|
||||
const plays = source.processRecentPlays([advancedState])
|
||||
expect(plays.length).to.be.eq(0);
|
||||
}
|
||||
|
||||
|
||||
it('Does not discover same Play after becoming active again (Non Positional Source)', function () {
|
||||
noScrobbleRediscoveryOnActive(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does not discover same Play after becoming active again (Positional Source)', function () {
|
||||
noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const noScrobbleStale = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
|
||||
// if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
|
||||
|
||||
let position = 0;
|
||||
let timeSince = 0;
|
||||
|
||||
// simulate polling playing source for 30 seconds, 10 second interval
|
||||
for(let i = 0; i < 3; i++) {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
// simulate polling another 20 seconds without any updates from the Source
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should not discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(0);
|
||||
|
||||
}
|
||||
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', function () {
|
||||
noScrobbleStale(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', function () {
|
||||
noScrobbleStale(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const scrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
|
||||
// if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
|
||||
|
||||
let position = 0;
|
||||
let timeSince = 0;
|
||||
|
||||
// simulate polling playing source for 30 seconds, 10 second interval
|
||||
for(let i = 0; i < 3; i++) {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
// simulate polling another 20 seconds without any updates from the Source
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 2;
|
||||
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should not discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(0);
|
||||
|
||||
// so that loop starts 1 second after "paused" position
|
||||
position -= 9;
|
||||
|
||||
// simulate ~50 seconds of listening (enough for scrobble)
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
for(let i = 0; i < 5; i++) {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
// new Play
|
||||
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
// should return discovered play with ~90 seconds of duration
|
||||
const plays = source.processRecentPlays([advancedState])
|
||||
expect(plays.length).to.be.eq(1);
|
||||
expect(plays[0].data.duration).to.be.closeTo(90, 2);
|
||||
|
||||
}
|
||||
|
||||
it('Does discover Play after becoming active again (Non Positional Source)', function () {
|
||||
scrobbleRediscoveryOnActive(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does discover Play after becoming active again (Positional Source)', function () {
|
||||
scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scrobble Threshold Checks', function() {
|
||||
|
||||
it('uses defaults when no user-configured thresholds are passed', function() {
|
||||
const results = timePassesScrobbleThreshold({}, 1, 1);
|
||||
expect(results.duration.threshold).to.eq(DEFAULT_SCROBBLE_DURATION_THRESHOLD);
|
||||
expect(results.percent.threshold).to.eq(DEFAULT_SCROBBLE_PERCENT_THRESHOLD);
|
||||
});
|
||||
|
||||
it('uses user-configured thresholds when passed', function() {
|
||||
const results = timePassesScrobbleThreshold({
|
||||
duration: 20,
|
||||
percent: 15
|
||||
}, 1, 1);
|
||||
expect(results.duration.threshold).to.eq(20);
|
||||
expect(results.percent.threshold).to.eq(15);
|
||||
});
|
||||
|
||||
it('passes when duration is above threshold', function() {
|
||||
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1);
|
||||
expect(results.duration.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
|
||||
it('passes when percent is above threshold', function() {
|
||||
const results = timePassesScrobbleThreshold({}, 30, 50);
|
||||
expect(results.percent.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
|
||||
it('handles zero duration', function() {
|
||||
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1, 0);
|
||||
expect(results.duration.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import timezone from "dayjs/plugin/timezone.js";
|
||||
import utc from "dayjs/plugin/utc.js";
|
||||
import { JsonPlayObject, ObjectPlayData, PlayMeta, PlayObject } from "../../../core/Atomic.js";
|
||||
import { sortByNewestPlayDate } from "../../utils.js";
|
||||
import { NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, ReportedPlayerStatus } from '../../common/infrastructure/Atomic.js';
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
@@ -134,6 +135,21 @@ export const normalizePlays = (plays: PlayObject[],
|
||||
return normalizedPlays;
|
||||
}
|
||||
|
||||
export const generatePlayerStateData = (options: Omit<PlayerStateDataMaybePlay, 'platformId'> & {playData?: ObjectPlayData, playMeta?: PlayMeta, platformId?: PlayPlatformId} = {}): PlayerStateDataMaybePlay => {
|
||||
let play: PlayObject = options.play ?? generatePlay(options.playData, options.playMeta);
|
||||
if(options.position !== undefined) {
|
||||
play.meta.trackProgressPosition = options.position;
|
||||
}
|
||||
return {
|
||||
platformId: options.platformId ?? [NO_DEVICE, NO_USER],
|
||||
sessionId: options.sessionId,
|
||||
play,
|
||||
status: options.status,
|
||||
position: options.position,
|
||||
timestamp: options.timestamp ?? dayjs()
|
||||
}
|
||||
}
|
||||
|
||||
export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject => {
|
||||
return {
|
||||
data: {
|
||||
|
||||
+2
-43
@@ -230,7 +230,7 @@ export const parseRetryAfterSecsFromObj = (err: any) => {
|
||||
// }
|
||||
const {
|
||||
response: {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
headers, // returned in superagent error
|
||||
} = {},
|
||||
retryAfter: ra // possible custom property we have set
|
||||
@@ -302,48 +302,6 @@ export const removeUndefinedKeys = <T extends Record<string, any>>(obj: T): T |
|
||||
return newObj;
|
||||
}
|
||||
|
||||
export const parseDurationFromTimestamp = (timestamp: any) => {
|
||||
if (timestamp === null || timestamp === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!(typeof timestamp === 'string')) {
|
||||
throw new Error('Timestamp must be a string');
|
||||
}
|
||||
if (timestamp.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsedRuntime = timestamp.split(':');
|
||||
let hours = '0',
|
||||
minutes = '0',
|
||||
seconds = '0',
|
||||
milli = '0';
|
||||
|
||||
switch (parsedRuntime.length) {
|
||||
case 3:
|
||||
hours = parsedRuntime[0];
|
||||
minutes = parsedRuntime[1];
|
||||
seconds = parsedRuntime[2];
|
||||
break;
|
||||
case 2:
|
||||
minutes = parsedRuntime[0];
|
||||
seconds = parsedRuntime[1];
|
||||
break;
|
||||
case 1:
|
||||
seconds = parsedRuntime[0];
|
||||
}
|
||||
const splitSec = seconds.split('.');
|
||||
if (splitSec.length > 1) {
|
||||
seconds = splitSec[0];
|
||||
milli = splitSec[1];
|
||||
}
|
||||
return dayjs.duration({
|
||||
hours: Number.parseInt(hours),
|
||||
minutes: Number.parseInt(minutes),
|
||||
seconds: Number.parseInt(seconds),
|
||||
milliseconds: Number.parseInt(milli)
|
||||
});
|
||||
}
|
||||
|
||||
export const remoteHostIdentifiers = (req: Request): RemoteIdentityParts => {
|
||||
const remote = req.connection.remoteAddress;
|
||||
const proxyRemote = Array.isArray(req.headers["x-forwarded-for"]) ? req.headers["x-forwarded-for"][0] : req.headers["x-forwarded-for"];
|
||||
@@ -525,6 +483,7 @@ export const intersect = (a: Array<any>, b: Array<any>) => {
|
||||
return Array.from(intersection);
|
||||
}
|
||||
|
||||
/** Return an array of elements from array a (first arg) that are not in array b (second arg) */
|
||||
export const difference = (a: Array<any>, b: Array<any>) => {
|
||||
const setA = new Set(a);
|
||||
const setB = new Set(b);
|
||||
|
||||
@@ -47,15 +47,55 @@ export const isPortReachable = async (port: number, opts: PortReachableOpts) =>
|
||||
}
|
||||
}
|
||||
|
||||
/** Test if a host:port is reachable via TCP
|
||||
*
|
||||
* Need to use net.connect instead of new.Socket() because the popular mocking libraries don't mock Socket
|
||||
*
|
||||
* https://github.com/gr2m/node-net-interceptor/issues/2
|
||||
* https://github.com/moll/node-mitm/issues/42
|
||||
*
|
||||
*/
|
||||
export const isPortReachableConnect = async (port: number, opts: PortReachableOpts) => {
|
||||
const {host, timeout = 1000} = opts;
|
||||
|
||||
const promise = new Promise(((resolve, reject) => {
|
||||
const client = net.connect({
|
||||
timeout,
|
||||
port,
|
||||
host
|
||||
}, () => {
|
||||
client.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
client.destroy();
|
||||
reject(err);
|
||||
});
|
||||
client.on('timeout', () => {
|
||||
reject(new Error(`Connection timed out after ${timeout}ms`));
|
||||
});
|
||||
}));
|
||||
|
||||
try {
|
||||
await promise;
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/);
|
||||
|
||||
export const normalizeWebAddress = (val: string): URLData => {
|
||||
export const normalizeWebAddress = (val: string, options: {defaultPath?: string} = {}): URLData => {
|
||||
let cleanUserUrl = val.trim();
|
||||
const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val);
|
||||
if (results !== undefined && results.groups && results.groups.length > 0) {
|
||||
cleanUserUrl = results.groups[0];
|
||||
}
|
||||
|
||||
const {defaultPath} = options;
|
||||
|
||||
let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: true});
|
||||
const u = new URL(normal);
|
||||
let port: number;
|
||||
@@ -72,6 +112,12 @@ export const normalizeWebAddress = (val: string): URLData => {
|
||||
normal = normal.replace('http:', 'https:');
|
||||
}
|
||||
}
|
||||
|
||||
if(u.pathname === '/' && defaultPath !== undefined) {
|
||||
u.pathname = defaultPath;
|
||||
normal = normalizeUrl(u.toString());
|
||||
}
|
||||
|
||||
return {
|
||||
url: u,
|
||||
normal,
|
||||
@@ -79,6 +125,50 @@ export const normalizeWebAddress = (val: string): URLData => {
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeWSAddress = (val: string, options: {defaultPort?: number | string, defaultPath?: string} = {}): URLData => {
|
||||
let cleanUserUrl = val.trim();
|
||||
const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val);
|
||||
if (results !== undefined && results.groups && results.groups.length > 0) {
|
||||
cleanUserUrl = results.groups[0];
|
||||
}
|
||||
if(!cleanUserUrl.match(/^(?:wss?|https?):/i)) {
|
||||
cleanUserUrl = `ws://${cleanUserUrl}`;
|
||||
}
|
||||
const normal = normalizeUrl(val, {removeTrailingSlash: false})
|
||||
const url = new URL(normal);
|
||||
|
||||
// default WS
|
||||
if (url.protocol === 'https:') {
|
||||
url.protocol = 'wss:';
|
||||
} else if (url.protocol === 'http:') {
|
||||
url.protocol = 'ws:';
|
||||
} else if(url.protocol === '') {
|
||||
url.protocol = 'ws:'
|
||||
}
|
||||
|
||||
const {defaultPort, defaultPath} = options;
|
||||
|
||||
let port: number;
|
||||
if(url.port === null || url.port === '') {
|
||||
if(defaultPort !== undefined) {
|
||||
url.port = defaultPort.toString();
|
||||
port = parseInt(url.port);
|
||||
} else {
|
||||
port = url.protocol === 'ws:' ? 80 : 443;
|
||||
}
|
||||
}
|
||||
|
||||
if(url.pathname === '/' && defaultPath !== undefined) {
|
||||
url.pathname = defaultPath;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
normal: url.toString(),
|
||||
port
|
||||
}
|
||||
}
|
||||
|
||||
export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => {
|
||||
const urlStr = userUrl ?? `http://localhost:${defaultPort}`;
|
||||
let cleanUserUrl = urlStr.trim();
|
||||
|
||||
@@ -76,7 +76,7 @@ const SECONDARY_REGEX_STRATS: RegExp[] = [SECONDARY_CAPTURED_REGEX, SECONDARY_FR
|
||||
* ^^^^^^^^^^^^^^**********************************************
|
||||
*
|
||||
* */
|
||||
export const PRIMARY_SECONDARY_SECTIONS_REGEX = new RegExp(/^(?<primary>.+?)(?<secondary>(?:[([]?(?:\Wft\.?|\Wfeat\.?|featuring|\Wvs\.)).*)/i);
|
||||
export const PRIMARY_SECONDARY_SECTIONS_REGEX = new RegExp(/^(?<primary>.+?)(?<secondary>(?:[([]?(?:\Wft\.?|\Wfeat\.?|featuring|\Wvs\.?)).*)/i);
|
||||
|
||||
/**
|
||||
* For matching the most common track/artist pattern that has a joiner
|
||||
|
||||
@@ -245,4 +245,56 @@ export const todayAwareFormat = (date: Dayjs, opts: {fullFormat?: string, todayF
|
||||
todayFormat = 'HH:mm:ssZ'
|
||||
} = opts;
|
||||
return date.format(date.isToday() ? todayFormat : fullFormat);
|
||||
}
|
||||
};
|
||||
export const parseDurationFromTimestamp = (timestamp: any) => {
|
||||
if (timestamp === null || timestamp === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!(typeof timestamp === 'string')) {
|
||||
throw new Error('Timestamp must be a string');
|
||||
}
|
||||
if (timestamp.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsedRuntime = timestamp.split(':');
|
||||
let hours = '0', minutes = '0', seconds = '0', milli = '0';
|
||||
|
||||
switch (parsedRuntime.length) {
|
||||
case 3:
|
||||
hours = parsedRuntime[0];
|
||||
minutes = parsedRuntime[1];
|
||||
seconds = parsedRuntime[2];
|
||||
break;
|
||||
case 2:
|
||||
minutes = parsedRuntime[0];
|
||||
seconds = parsedRuntime[1];
|
||||
break;
|
||||
case 1:
|
||||
seconds = parsedRuntime[0];
|
||||
}
|
||||
const splitSec = seconds.split('.');
|
||||
if (splitSec.length > 1) {
|
||||
seconds = splitSec[0];
|
||||
milli = splitSec[1];
|
||||
}
|
||||
return dayjs.duration({
|
||||
hours: Number.parseInt(hours),
|
||||
minutes: Number.parseInt(minutes),
|
||||
seconds: Number.parseInt(seconds),
|
||||
milliseconds: Number.parseInt(milli)
|
||||
});
|
||||
};
|
||||
|
||||
export type Milliseconds = number;
|
||||
|
||||
export const timeToHumanTimestamp = (val: ReturnType<typeof dayjs.duration> | Milliseconds): string => {
|
||||
const ms = dayjs.isDuration(val) ? Math.abs(val.asMilliseconds()) : val;
|
||||
|
||||
// less than one hour
|
||||
if(ms < 3600000) {
|
||||
// EX 14:07
|
||||
return new Date(ms).toISOString().substring(14, 19)
|
||||
}
|
||||
// EX 01:15:45
|
||||
return new Date(ms).toISOString().substring(11, 19);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {Fragment, useCallback} from 'react';
|
||||
import React, {Fragment, useCallback, useMemo} from 'react';
|
||||
import StatusCardSkeleton, {StatusCardSkeletonData} from "./StatusCardSkeleton";
|
||||
import SkeletonParagraph from "../skeleton/SkeletonParagraph";
|
||||
import {Link} from "react-router-dom";
|
||||
@@ -6,7 +6,7 @@ import {sourceAdapter} from "../../status/ducks";
|
||||
import {RootState} from "../../store";
|
||||
import {connect, ConnectedProps} from "react-redux";
|
||||
import Player from "../player/Player";
|
||||
import {useStartSourceMutation} from "./sourceDucks";
|
||||
import {useStartSourceMutation, useListenSourceMutation} from "./sourceDucks";
|
||||
import './statusCard.scss';
|
||||
|
||||
export interface SourceStatusCardData extends StatusCardSkeletonData, PropsFromRedux {
|
||||
@@ -15,10 +15,10 @@ export interface SourceStatusCardData extends StatusCardSkeletonData, PropsFromR
|
||||
|
||||
const statusToStatusType = (status: string) => {
|
||||
const lower = status.toLowerCase();
|
||||
if(lower.includes('running') || lower.includes('polling') || lower.includes('awaiting data')) {
|
||||
if(lower.includes('running') || lower.includes('polling') || lower.includes('received data')) {
|
||||
return 'active';
|
||||
}
|
||||
if(lower.includes('idle')) {
|
||||
if(lower.includes('idle') || lower.includes('awaiting data')) {
|
||||
return 'warn';
|
||||
}
|
||||
return 'error';
|
||||
@@ -39,9 +39,28 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
let body = <SkeletonParagraph/>;
|
||||
|
||||
const [startPut, startResult] = useStartSourceMutation();
|
||||
const [listenPut, listenResult] = useListenSourceMutation();
|
||||
|
||||
const tryStart = useCallback((name: string, type: string, force?: boolean) => startPut({name, type, force}), [startPut]);
|
||||
const tryListen = useCallback((name: string, type: string, currentListening?: boolean) => {
|
||||
// cycle through states
|
||||
let nextListen: boolean | undefined;
|
||||
switch(currentListening) {
|
||||
case true:
|
||||
nextListen = false;
|
||||
break;
|
||||
case false:
|
||||
nextListen = undefined;
|
||||
break;
|
||||
case undefined:
|
||||
nextListen = true;
|
||||
break;
|
||||
}
|
||||
listenPut({name, type, listening: nextListen});
|
||||
}, [listenPut]);
|
||||
let startSourceElement = null;
|
||||
let manualListenElement = null;
|
||||
let subtitleElement = null;
|
||||
|
||||
if(data !== undefined)
|
||||
{
|
||||
@@ -57,7 +76,10 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
type,
|
||||
players = {},
|
||||
sot,
|
||||
supportsUpstreamRecentlyPlayed
|
||||
supportsUpstreamRecentlyPlayed,
|
||||
supportsManualListening,
|
||||
manualListening,
|
||||
systemListeningBehavior
|
||||
} = data;
|
||||
if(type === 'listenbrainz' || type === 'lastfm') {
|
||||
header = `${display} (Source)`;
|
||||
@@ -70,6 +92,27 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
startText = status === 'Running' ? 'Reinit' : 'Init'
|
||||
}
|
||||
|
||||
const ml = useMemo(() => {
|
||||
if(listenResult.status !== 'fulfilled' || listenResult.data === undefined) {
|
||||
return manualListening;
|
||||
}
|
||||
return (listenResult.data as any).listening;
|
||||
}, [manualListening, listenResult]);
|
||||
|
||||
if(supportsManualListening) {
|
||||
manualListenElement = (<Fragment>
|
||||
<span>Should Scrobble:</span>
|
||||
<div onClick={() => tryListen(name, type, ml)}
|
||||
className="capitalize underline cursor-pointer inline mr-1 ml-1">
|
||||
{ml !== undefined ? (ml ? 'Yes' : 'No') : null}
|
||||
{ml === undefined ? <span>System {systemListeningBehavior ? '(Yes)' : '(No)'}</span> : null}
|
||||
</div>
|
||||
{/* {ml !== undefined ? <div onClick={() => tryListen(name, type, undefined)}
|
||||
className="capitalize underline cursor-pointer inline">Clear
|
||||
</div> : null} */}
|
||||
</Fragment>);
|
||||
}
|
||||
|
||||
startSourceElement = (<Fragment>
|
||||
<div onClick={() => tryStart(name, type)}
|
||||
className="capitalize underline cursor-pointer inline mr-1">{startText}
|
||||
@@ -79,6 +122,12 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
</div>)
|
||||
</Fragment>);
|
||||
|
||||
if(manualListenElement !== null) {
|
||||
subtitleElement = <Fragment>{manualListenElement} | {startSourceElement}</Fragment>
|
||||
} else {
|
||||
subtitleElement = startSourceElement;
|
||||
}
|
||||
|
||||
const platformIds = Object.keys(players);
|
||||
|
||||
const discovered = (!hasAuth || authed) ? <Link to={`/recent?type=${type}&name=${name}`}>Tracks Discovered</Link> : <span>Tracks Discovered</span>;
|
||||
@@ -103,7 +152,7 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
title={header}
|
||||
subtitle={name}
|
||||
status={status}
|
||||
subtitleRight={startSourceElement}
|
||||
subtitleRight={subtitleElement}
|
||||
statusType={statusToStatusType(status)}>
|
||||
{body}
|
||||
</StatusCardSkeleton>
|
||||
|
||||
@@ -18,8 +18,23 @@ export const sourceApi = createApi({
|
||||
force: params.force
|
||||
}
|
||||
})
|
||||
}),
|
||||
listenSource: builder.mutation<undefined, {
|
||||
name: string,
|
||||
type: string,
|
||||
listening?: boolean
|
||||
}>({
|
||||
query: (params) => ({
|
||||
url: '/source/listen',
|
||||
method: 'POST',
|
||||
params: {
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
listening: params.listening
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const {useStartSourceMutation} = sourceApi;
|
||||
export const {useStartSourceMutation, useListenSourceMutation} = sourceApi;
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface SourceStatusData {
|
||||
players: Record<string, SourcePlayerJson>
|
||||
sot: SOURCE_SOT_TYPES
|
||||
supportsUpstreamRecentlyPlayed: boolean;
|
||||
supportsManualListening: boolean;
|
||||
manualListening?: boolean
|
||||
systemListeningBehavior?: boolean
|
||||
}
|
||||
|
||||
export interface ClientStatusData {
|
||||
|
||||
Reference in New Issue
Block a user