Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04514f6a1 | ||
|
|
00e9755fb1 | ||
|
|
5ba4729f83 | ||
|
|
e5df4f2752 | ||
|
|
d11db4cb8d | ||
|
|
14f5f60c11 | ||
|
|
4d7eadbd38 | ||
|
|
747b62289a | ||
|
|
1eeb5e6b08 | ||
|
|
ac01348266 | ||
|
|
3028f83d47 | ||
|
|
a02d43acea | ||
|
|
c7679a0a62 | ||
|
|
d11ac3d6ed | ||
|
|
8a09c91713 | ||
|
|
81d24f1753 | ||
|
|
5be12aab15 | ||
|
|
47c600ed8d | ||
|
|
5f84fefd5d | ||
|
|
a143aa4193 | ||
|
|
23a3dfa5d1 | ||
|
|
3005d36db0 | ||
|
|
ada9d70be9 | ||
|
|
9d839db2e1 | ||
|
|
ca20b23fbc | ||
|
|
577cff3abf | ||
|
|
c4786bdfe2 | ||
|
|
df0da7054c | ||
|
|
272a336a22 | ||
|
|
ec4a51074e | ||
|
|
5a4bcb2949 | ||
|
|
77387c292b | ||
|
|
4caf1c8cdb | ||
|
|
981805aa96 | ||
|
|
60e3242c44 | ||
|
|
e7e1417b0b | ||
|
|
bf765be174 | ||
|
|
e27c301932 | ||
|
|
fc4606585d | ||
|
|
fd80101a7a | ||
|
|
2cbc5542d1 | ||
|
|
6f15f15722 | ||
|
|
ff1d9248c6 | ||
|
|
218ece8f5c | ||
|
|
3adac7c6e0 | ||
|
|
1c78da7985 | ||
|
|
457279465c | ||
|
|
d551c8563f | ||
|
|
75bd4a04d6 | ||
|
|
339232c368 | ||
|
|
8741ff25ea | ||
|
|
8a952cb576 | ||
|
|
eeb55aa02e | ||
|
|
6a81fc1be2 | ||
|
|
a47e2a6242 | ||
|
|
dadaa6df35 | ||
|
|
fb19191209 | ||
|
|
2f53a60da1 | ||
|
|
ab2f397fab | ||
|
|
db2dd8fb8b | ||
|
|
135439f509 | ||
|
|
d743b849fe | ||
|
|
9885768cad | ||
|
|
c6e6bbb87b | ||
|
|
bbc2351ebc | ||
|
|
b547db9045 | ||
|
|
28c2e9873a | ||
|
|
6bcd3f9516 | ||
|
|
11b2632d02 | ||
|
|
fb6dbe307d | ||
|
|
2474b792c3 | ||
|
|
3007c949a0 | ||
|
|
63533534df | ||
|
|
5c03062976 | ||
|
|
dbe41fa5ab | ||
|
|
5454f817e5 | ||
|
|
8d8f8260b5 | ||
|
|
62bb00571f | ||
|
|
c10537d658 | ||
|
|
e9ccc319e9 | ||
|
|
54803c6ef9 | ||
|
|
a3d37a864a | ||
|
|
394570e5a3 | ||
|
|
2bbfdf424f | ||
|
|
9785ad6ba7 | ||
|
|
fe7d77c03d | ||
|
|
0e7aa92f5b | ||
|
|
2ff80283b9 | ||
|
|
4f34b1a055 | ||
|
|
4effdd6df5 | ||
|
|
af3ce9dea7 | ||
|
|
f0c5cb524f |
@@ -13,6 +13,7 @@ config/*.json
|
||||
config/*.bak
|
||||
config/*.p8
|
||||
/docs
|
||||
dist
|
||||
/logs
|
||||
.flatpak-builder
|
||||
**/generated-sources.*
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
groups:
|
||||
sec-updates:
|
||||
applies-to: "security-updates"
|
||||
patterns:
|
||||
- "*"
|
||||
directories:
|
||||
- "/"
|
||||
- "/docsite"
|
||||
@@ -17,3 +22,16 @@ updates:
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
groups:
|
||||
sec-updates:
|
||||
applies-to: "security-updates"
|
||||
patterns:
|
||||
- "*"
|
||||
version-updates:
|
||||
applies-to: "version-updates"
|
||||
patterns:
|
||||
- "*"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -38,6 +38,11 @@ jobs:
|
||||
run: npm run build
|
||||
working-directory: ./docsite
|
||||
|
||||
- name: Replace canonical
|
||||
working-directory: ./docsite/build
|
||||
# https://stackoverflow.com/a/74211242/1469797
|
||||
run: grep '<link data-rh="true" rel="canonical" href="https://foxxmd.github.io' . -lr | xargs sed -i 's/<link data-rh="true" rel="canonical" href="https:\/\/foxxmd.github.io\/docs/<link data-rh="true" rel="canonical" href="https:\/\/docs.multi-scrobbler.app/g'
|
||||
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
@@ -67,4 +72,10 @@ jobs:
|
||||
\"client_payload\": {
|
||||
\"commit\": \"${{ github.sha }}\"
|
||||
}
|
||||
}"
|
||||
}"
|
||||
- name: Trigger CF Pages build
|
||||
if: ${{ vars.CF_PAGES == 'true' }}
|
||||
run: |
|
||||
# trigger CF pages hook deployment
|
||||
curl -d "" \
|
||||
"${{ secrets.CF_PAGES_HOOK }}"
|
||||
@@ -51,7 +51,9 @@ jobs:
|
||||
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
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "BUILD_DATE=$date" >> $GITHUB_ENV
|
||||
env:
|
||||
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
|
||||
VARS_GHCR_SLUG: ${{vars.GHCR_SLUG}}
|
||||
@@ -104,6 +106,7 @@ jobs:
|
||||
context: ${{github.event.pull_request.head.repo.clone_url}}#${{github.event.pull_request.head.sha}}
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
|
||||
@@ -117,7 +120,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 #4.6.2
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #7.0.0
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -136,7 +139,7 @@ jobs:
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #4.3.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -162,7 +165,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 #5.9.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf #6.0.0
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
@@ -220,7 +223,7 @@ jobs:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
steps:
|
||||
- name: Create comment
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 #2.9.4
|
||||
uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff #3.0.3
|
||||
with:
|
||||
recreate: true
|
||||
header: "pr-release"
|
||||
|
||||
@@ -66,6 +66,8 @@ jobs:
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "BUILD_DATE=$date" >> $GITHUB_ENV
|
||||
env:
|
||||
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
|
||||
VARS_GHCR_SLUG: ${{vars.GHCR_SLUG}}
|
||||
@@ -126,6 +128,7 @@ jobs:
|
||||
with:
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
|
||||
@@ -140,7 +143,7 @@ jobs:
|
||||
env:
|
||||
VARS_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 #4.6.2
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #7.0.0
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -159,7 +162,7 @@ jobs:
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #4.3.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -185,7 +188,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 #5.9.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf #6.0.0
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
node-version:
|
||||
description: "Node version"
|
||||
required: false
|
||||
default: '20.x'
|
||||
default: '24.x'
|
||||
type: string
|
||||
ref:
|
||||
description: "Ref to checkout"
|
||||
|
||||
@@ -65,7 +65,8 @@ 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/
|
||||
COPY --chown=abc:abc docsite/patches ./docsite/patches
|
||||
COPY --chown=abc:abc docsite/package*.json docsite/tsconfig* ./docsite/
|
||||
|
||||
# for debugging, so the build fails faster when timing out (arm64)
|
||||
#RUN npm config set fetch-retries 1 && \
|
||||
@@ -92,18 +93,21 @@ RUN npm run build:parallel && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
|
||||
FROM base AS app
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/FoxxMD/multi-scrobbler"
|
||||
|
||||
COPY --chown=abc:abc *.json *.js *.ts index.html ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
# frontend build from vite/esbuild
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
# backend
|
||||
COPY --from=build --chown=abc:abc /app/src/backend /app/src/backend
|
||||
COPY --from=build --chown=abc:abc /app/src/core /app/src/core
|
||||
# docusaurus docs
|
||||
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
|
||||
COPY --from=base /usr/bin /usr/bin
|
||||
COPY --from=base /usr/lib /usr/lib
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
ENV COLORED_STD=true
|
||||
|
||||
# https://stackoverflow.com/a/63640896/1469797
|
||||
ARG APP_BUILD_VERSION
|
||||
@@ -114,8 +118,22 @@ RUN npm ci --omit=dev --no-audit \
|
||||
&& npm uninstall ts-json-schema-generator \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& npx @usex/prune-mod -w \
|
||||
&& rm -rf /root/.cache
|
||||
|
||||
ARG BUILD_DATE=0
|
||||
|
||||
LABEL org.opencontainers.image.version="$APP_BUILD_VERSION" \
|
||||
org.opencontainers.image.source="https://github.com/FoxxMD/multi-scrobbler" \
|
||||
org.opencontainers.image.documentation="https://docs.multi-scrobbler.app" \
|
||||
org.opencontainers.image.description="Scrobble from multiple sources to multiple clients" \
|
||||
org.opencontainers.image.title="Multi-Scrobbler" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.authors="FoxxMD" \
|
||||
org.opencontainers.image.created="$BUILD_DATE" \
|
||||
org.opencontainers.image.url="https://docs.multi-scrobbler.app" \
|
||||
maintainer="FoxxMD" \
|
||||
build_version="$APP_BUILD_VERSION"
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"data": {
|
||||
"url": "http://192.168.1.161:9980",
|
||||
"apiKey": "change-me",
|
||||
"interval": 5
|
||||
"interval": 3
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,6 +4,8 @@ services:
|
||||
multi-scrobbler:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
BUILD_DATE: "${BUILD_DATE}"
|
||||
env_file:
|
||||
- path: ./.env
|
||||
required: false
|
||||
|
||||
@@ -114,6 +114,12 @@ It means the JSON in your configuration file is not valid. Copy and paste your c
|
||||
|
||||
## Scrobbling Issues
|
||||
|
||||
### How do I prevent duplicate scrobbles?
|
||||
|
||||
Multi-scrobbler has a [robust duplicate scrobble detection system](/configuration/duplicates#how-duplicates-are-detected) that should handle this for you in the majority of usecases. If you have a [simple setup without any circular dependencies](/configuration/duplicates#valid-configs) then you are already protected against duplicates.
|
||||
|
||||
If your scenario is more complex or you have a service(s) acting as **both** a **Client** and **Source** you should refer to the [**Duplicate Scrobble Guidance**](/configuration/duplicates#valid-configs) page to determine if you can avoid a configuration that may result in duplicate scrobbles.
|
||||
|
||||
### Last.fm does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scrobble) where the **artist** field is only one string and Last.fm does not recognize (play well) with "combined" artists.
|
||||
|
||||
@@ -34,60 +34,9 @@ For each Play, MS fetches (cached) scrobbles from the Client in a time range inc
|
||||
* Temporal closeness of the Play's timestamp to the existing scrobble's timestamp
|
||||
* Whether MS detected the Play as a repeat (applicable to Sources that report realtime Player data)
|
||||
|
||||
Detailed scoring breakdowns against each existing scrobble are logged at the `TRACE` level.
|
||||
Detailed scoring breakdowns against each existing scrobble are logged at the `TRACE` logging level or in the [debug data](/help#copy-play-debug-data) for a scrobble.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Detailed Explanation</summary>
|
||||
|
||||
A **candidate** (to be scrobbled) Play is first transformed using the configured [`compare.candidate` Hook](/configuration/transforms/#hook), if any exists.
|
||||
|
||||
Next, MS checks (up to) the last 100 scrobbles *in-memory* scrobbles that it has made. These are *not* from the Client but the actual scrobbles MS made while it has been running. The data from these scrobbles is much richer than what is usually parsed from the Client which makes it easier to detect duplicates from.
|
||||
|
||||
If no in-memory scrobble matches then MS starts comparing the candidate against historical scrobbles fetched from an inclusive time range of the candidate's timestamp.
|
||||
|
||||
<h3>Matching Title/Artist/Album</h3>
|
||||
|
||||
For these string-based values MS uses an [token-order-invariant](https://github.com/FoxxMD/multi-scrobbler/blob/0dfa4c7aad6df98e13aee6d395827665c2414adb/src/backend/utils/StringUtils.ts#L299) method that scores string similarity based on a (token count) weighted average of two [similarity](https://foxxmd.github.io/string-sameness/#md:strategies) algorithms, [Levenshtien Distance](https://en.wikipedia.org/wiki/Levenshtein_distance) and [Dice's Coefficient](https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient). The weighted average ensures that very long strings (Track titles) are scored with a confidence proportional to their length.
|
||||
|
||||
<h3>Matching Timestamp</h3>
|
||||
|
||||
Timestamps are scored on how temporally close they are. There are four possible scores with decreasing value:
|
||||
|
||||
* **Exact** - Timestamps are within 1 second of each other
|
||||
* **Close** - Timestamps are within `threshold` seconds of each other
|
||||
* This is determined by the smallest update interval of Source
|
||||
* Some Sources (subsonic) only update every 60 seconds so this is the smallest "close" value possible. Most are 10 seconds. Signified by `(Needed <10s)` in the breakdown example below.
|
||||
* **Fuzzy** - One timestamp is within `threshold` seconds of the *end* of the other timestamp
|
||||
* Sources can set the scrobble timestamp at different times. Some do it when the track is started listening to, some when it ends, some when the *player* stops.
|
||||
* Where possible, MS knows and keeps track of when this timestamp *should* be, for each Source. If it's not possible then Fuzzy may be allowed.
|
||||
* **None** - There is no correlation between timestamps
|
||||
|
||||
<h3>Scoring and Breakdows</h3>
|
||||
|
||||
A candidate Play must score >= 1 to be detected as a duplicate of an existing scrobble.
|
||||
|
||||
Each score and a breakdown of the scores for its individual components can be see at the `TRACE` logging level or in the [debug data](/help#copy-play-debug-data) for a scrobble. An example:
|
||||
|
||||
```
|
||||
* Artist: 0.06 * 0.3 = 0.02
|
||||
* Title: 0.04 * 0.4 = 0.02
|
||||
* Time: (Exact) 1 * 0.5 = 0.50
|
||||
* Existing: 19:13:33-04:00 - Candidate: 19:13:33-04:00
|
||||
* Temporal Sameness: Exact
|
||||
* Play Diff: 0s (Needed <10s)
|
||||
* Range Comparison N/A
|
||||
Score 0.54 => No Match
|
||||
```
|
||||
|
||||
In each component equation, the first number is the similarity (or temporal closeness).
|
||||
|
||||
* 0 = no correlation
|
||||
* 1 = exactly the same
|
||||
|
||||
The second number is the *weight* of that component in the final score.
|
||||
|
||||
</details>
|
||||
See the [**Duplicate Scrobble Guidance**](/configuration/duplicates#how-duplicates-are-detected) page to learn how duplicates are detected and how you can ensure your configuration avoids possible duplicates.
|
||||
|
||||
### Dead Scrobbles
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Using the URL path `/apis/listenbrainz` [Koito docs describe](https://koito.io/g
|
||||
|
||||
:::tip
|
||||
|
||||
Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito config set [`KOITO_ALLOWED_HOSTS`](https://koito.io/reference/configuration/#koito_allowed_hosts) to the IP the multi-scrobbler dashboard is accessible from.
|
||||
For Koito versions `<=0.1.7`, you must have the environment variable `KOITO_ALLOWED_HOSTS` set correctly in your Koito environment/container. Ensure it is set to the IP address or domain name that you use to access Koito with e.g. `koito.mydomain.com`.
|
||||
|
||||
:::
|
||||
|
||||
@@ -57,4 +57,4 @@ Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito
|
||||
| `KOITO_TOKEN` | Yes | | API Key from your Koito Account |
|
||||
| `KOITO_USER` | Yes | | Your Koito username |
|
||||
| `KOITO_URL` | No | | The base URL for the Koito server |
|
||||
</Config>
|
||||
</Config>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Duplicate Scrobble Guidance
|
||||
description: Common Scenarios and Solutions for Duplicate Scrobbles
|
||||
---
|
||||
|
||||
Multi-scrobbler's flexibility in configuration allows you to build some complex pipelines for scrobbling but it can also be a footgun for duplicating scrobbles.
|
||||
|
||||
## How Duplicates Are Detected
|
||||
|
||||
Multi-scrobbler uses multiple techniques for evaluating the **similarity** of text in scrobbles against existing data.
|
||||
|
||||
These techniques are used on all text from a scrobble (title, artist, album...):
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Character and string normalization</summary>
|
||||
|
||||
Symbols and multiple whitespaces are removed. All characters are converted to lowercase. Non ascii characters are converted to their ascii counterparts.
|
||||
|
||||
```
|
||||
this string! is the. same => this string is the same
|
||||
this string is the same => this string is the same
|
||||
ThIs STRING iS THe SAMe => this string is the same
|
||||
|
||||
Dina Ögon => Dina Ogon
|
||||
Nanã => Nana
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Word-order invariant text similarity</summary>
|
||||
|
||||
Strings with multiple words (tokens) are re-arranged so that tokens are ordered to be as close to other, similar tokens are possible:
|
||||
|
||||
Example 1
|
||||
|
||||
```
|
||||
String A: there change is => there change is
|
||||
String B: there is change => there change is
|
||||
```
|
||||
|
||||
Example 2
|
||||
|
||||
```
|
||||
String A: you, doin hwat are => you doin hwat are
|
||||
String B: what are you doing => you doing what are
|
||||
```
|
||||
|
||||
Then, the strings are independently compared using [Levenshtein Distance](https://en.wikipedia.org/wiki/Levenshtein_distance) and [Dice's Coefficient](https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient). The algorithm that gives the highest score for similarity is used so that matching is aggressive.
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
For **Artists**, Multi-scrobbler attempts to extract unique Artists from common patterns in **single-string** artist values and track **titles**.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Matching Common Artist Patterns</summary>
|
||||
|
||||
Common joiners and patterns are detected in artist/track strings and the artists are extracted. Example of common joiners are: `ft.` `feat` `vs.` etc...
|
||||
|
||||
Example 1
|
||||
|
||||
```
|
||||
Melendi, Ryan Lewis, The Righteous Brothers (featuring Joan Jett & The Blackhearts, Robin Schulz)
|
||||
|
||||
becomes
|
||||
|
||||
Melendi, Ryan Lewis, The Righteous Brothers, Joan Jett, The Blackhearts, Robin Schulz
|
||||
```
|
||||
|
||||
Example 2
|
||||
|
||||
```
|
||||
Childish Gambino - 12.38 (feat. 21 Savage, Ink & Kadhja Bonet)
|
||||
|
||||
becomes
|
||||
|
||||
Childish Gambino, 21 Savage, Ink, Kadhja Bonet - 12.38
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
* MS uses "smart" heuristics to prevent artists with actual punctuation (`Tyler, The Creator`) from accidentally being separated
|
||||
* The extraction examples above are done **only for matching** other scrobble data (which is also parsed like the above), the resulting scrobble data is never transformed unless the user explicitly configures it via a [native transform](/configuration/transforms/native)
|
||||
|
||||
:::
|
||||
|
||||
</details>
|
||||
|
||||
The **timestamps** for Scrobbles are compared at different levels of granularity based on where they originate from.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Temporally Comparing Scrobbles</summary>
|
||||
|
||||
Timestamp closeness for two scrobbles can be classified in one of these states:
|
||||
|
||||
**Exact**
|
||||
|
||||
Timestamps differ by 1 seconds or less.
|
||||
|
||||
**Close**
|
||||
|
||||
Timestamps differ by less than the known granularity of the Source they are from. For most Sources (and all Clients) this is 10 seconds or less. For some sources (subsonic) the granularity is 60 seconds because they only report "now playing" changes every 60 seconds.
|
||||
|
||||
**During**
|
||||
|
||||
If the Play was monitoring in real-time by Multi-scrobbler then it knows the time range during which the Play was listened to. If an existing scrobble with similar artist/track/album has a timestamp that occurred *during* the known listening time then it is classified as during.
|
||||
|
||||
**Fuzzy**
|
||||
|
||||
If the timestamp occurs within a few seconds +/- of (existing scrobble + track duration) then, potentially, the Source timestamped the existing scrobble at the *end* of when the user listened to it, rather than when the user *started* listening to it. This is common for Spotify plays.
|
||||
|
||||
___
|
||||
|
||||
Each of the above classification results in a different score that is *roughly* descending from the order given above. Exact score is based on the Souce of the scrobble and if it was monitored in real-time by Multi-Scrobbler.
|
||||
|
||||
</details>
|
||||
|
||||
Finally, all of the above comparison/normalization techniques are combined when comparing a "new" Play/Scrobble against existing data. Each comparison is given a weighted score. If the score is above the match threshold then it is marked as a duplicated.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Match Scoring Example</summary>
|
||||
|
||||
```
|
||||
Frédéric Chopin - Ballade No. 4 in F Minor, Op. 52 @ 2026-05-04T13:01:51-04:00 <-- New scrobble
|
||||
Frédéric Chopin / Krystian Zimerman - Ballade No. 4 in F Minor, Op. 52 @ 2026-05-04T13:01:48-04:00 <-- Existing scrobble from client
|
||||
```
|
||||
|
||||
After comparing against all existing scrobbles from the client, in a timerange around the new scrobble, MS finds a match and logs the match along with the score breakdown of the consistuent parts:
|
||||
|
||||
```
|
||||
Source:
|
||||
Frédéric Chopin - Ballade No. 4 in F Minor, Op. 52 @ 2026-05-04T13:01:51-04:00 (S)
|
||||
|
||||
Closest Scrobble:
|
||||
Frédéric Chopin / Krystian Zimerman - Ballade No. 4 in F Minor, Op. 52 @ 2026-05-04T13:01:48-04:00 (S)
|
||||
|
||||
Artist: (0.30 + Whole Match Bonus 0.52) * (0.3 + Whole Match Bonus 0.05) = 0.29
|
||||
Title: 1.00 * 0.4 = 0.40
|
||||
Time: (Close) 1 * 0.5 = 0.50
|
||||
Time Detail => Existing: 13:01:48-04:00 - Candidate: 13:01:51-04:00 | Temporal Sameness: Close | Play Diff: 3s (Needed <10s) | Range Comparison N/A
|
||||
Score 1.19 => Matched!
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
All of the above techniques and scoring gives Multi-scrobbler a *very good chance* of detecting duplicate scrobbles, regardless of where they were originally from. Of Multi-Scrobbler's [400+ unit tests](https://github.com/FoxxMD/multi-scrobbler/tree/master/src/backend/tests), 70+ are related to testing scoring, artist extraction, and [fuzzing](https://en.wikipedia.org/wiki/Fuzzing) string normalization/simiarlity.
|
||||
|
||||
The dupe detection is so good that it is virtually gauranteed for [most configurations](#valid-configs) and is used for *backlogging* missed scrobbles for Sources that support fetching history.
|
||||
|
||||
**However**, it's not possible to gaurantee this for *all* possible Multi-scrobbler configurations. See the below sections to learn more.
|
||||
|
||||
## Configurations Where Duplicates DO NOT Occur {#valid-configs}
|
||||
|
||||
* Any Source/Client configuration where there is only one Source and one Client and they are not the same service/account
|
||||
* Any Source/Client configuration where scrobbles only flow in **one direction**
|
||||
* IE you *do not* have a Last.fm **Client** Account A that is *also* a Last.fm **Source** Account A
|
||||
|
||||
:::tip
|
||||
|
||||
**The majority of simple Multi-scrobbler configurations fall into these categories. If your workflow is straightforward you do not need to worry about duplicates at all.**
|
||||
|
||||
:::
|
||||
|
||||
Multi-scrobbler can handle recieving the same data from a Source multiple times. This is fine because the data is *always the same* and *always unique* to each Source. Since MS scrobbled the data to your clients you can be pretty confident that the *same* cleanup/matching process for that data will result in an indentical/similar match to the existing scrobble.
|
||||
|
||||
IE Same inputs in -> same inputs out.
|
||||
|
||||
**Even this scenario with multiple, mixed Sources is OK:**
|
||||
|
||||
<img className='image-center' src={require('/img/diagrams/unidirectional-flow.png').default}/>
|
||||
|
||||
Because the scrobbles coming from each **Source** are **independent** of each other. And they end up in Clients that are not *also* used as Sources.
|
||||
|
||||
:::tip
|
||||
|
||||
This is a common scenario when scrobbles are *backlogged* from a Source IE on startup MS fetches the last 50 Plays from Spotify/Lastfm/Listenbrainz.
|
||||
|
||||
:::
|
||||
|
||||
## Configurations Where Duplicates CAN Occur {#invalid-configs}
|
||||
|
||||
### Upstream Scrobbles Sent to Multiple Sources
|
||||
|
||||
In this scenario your device using Spotify is configured in MS as a Spotify **Source** but it **also** is scrobbling to Last.fm (via Pano Scrobbler, as an example).
|
||||
|
||||
<img className='image-center' src={require('/img/diagrams/dupe sources.png').default}/>
|
||||
|
||||
|
||||
❌ **Time Race Condition**: Potentially, the scrobbles arrive in Multi-scrobbler at exactly the same time. There are no existing scrobbles in Koito to check against, yet. So Multi-scrobbler scrobbles both to Koito, resulting in duplicate scrobbles.
|
||||
|
||||
❌ **Different Scrobble Data**: Pano Scrobbler, or Last.fm, [may modify the scrobble data.](#lastfm) When MS recieves the scrobble from Last.fm it may be *just different enough* that it does not get detected as a duplicate.
|
||||
|
||||
### Sources and Clients are Dependent {#dependent-source-client}
|
||||
|
||||
In this scenario, you have a **Client** that is also a **Source** and that Source is feeding scrobbles to a different Client that receives scrobbles from everywhere (or has some overlap with the original Client).
|
||||
|
||||
<img className='image-center' src={require('/img/diagrams/duped client source.png').default}/>
|
||||
|
||||
❌ **Different Scrobble Data**: [Last.fm may modify the scrobble data.](#lastfm) When MS recieves the scrobble from Last.fm it may be *just different enough* that it does not get detected as a duplicate.
|
||||
|
||||
❌ **Consistency Issues**: If the Koito Client has a backed up queue or is not currently scrobbling (imagine it was Last.fm or Listenbrainz, and they had upstream availability issues), then there is no existing scrobble yet and nothing to match against.
|
||||
|
||||
## Configuration Solutions to Avoid Duplicates
|
||||
|
||||
### Independent Sources
|
||||
|
||||
Use a configuration mentioned in [Configurations Where Duplicates DO NOT Occur](#valid-configs): make sure all of your Sources are **independent**. The activity/scrobbles from each Source do not have any upstream duplicates of activity.
|
||||
|
||||
<img className='image-center' src={require('/img/diagrams/independent source.png').default}/>
|
||||
|
||||
### Alternative Sources
|
||||
|
||||
Imagine you have a scenario where a device/player can **only** scrobble to Last.fm. But you want to use MS for all other devices/sources and still have all your scrobbles end up in your "main" Last.fm account for social reasons/stats.
|
||||
|
||||
Instead of using the same Last.fm account for all activity and potentially having [dependent client/sources](#dependent-source-client), create an *additional* last.fm account that is used *only for the device that needs it*. This ensures the scrobbles from that device are isolated from the main account and do not prevent duplicates, so you can continue to use the main account as an MS Client:
|
||||
|
||||
<img className='image-center' src={require('/img/diagrams/alt source.png').default}/>
|
||||
|
||||
### Mirror-Only Clients or Limiting Scrobble Destination {#isolated-sources}
|
||||
|
||||
In Multi-scrobbler, by default, all Sources scrobble to all Clients. Using [Limit Scrobble Destinations](/configuration/sources/#limiting-scrobble-destination) configuration can ensure that your dependent Source/Clients only scrobble to destinations where the scrobbles won't be duplicated. And/or limit other Sources to *not* scrobble to dependent Sources/Clients.
|
||||
|
||||
Here is an example where all Sources only scrobble to a *dependent* Last.fm Client, and then the *dependent* Last.fm Source only scrobbles to a "mirror-only" (backup) Koito Client:
|
||||
|
||||
<img className='image-center' src={require('/img/diagrams/mirror source.png').default}/>
|
||||
|
||||
### Normalize All Scrobbles
|
||||
|
||||
If using one of the above solutions is not possible you can also try to *aggressively normalize all scrobble data* using [the Musicbrainz transform stage](/configuration/transforms/musicbrainz).
|
||||
|
||||
In this scenario you still have [dependent Source/Clients](#dependent-source-client) that are [not isolated](#isolated-sources) but you can *potentially* improve duplicate detection by first using a transform stage to match scrobbles against the Musibrainz database and normalize their track/artist/album data.
|
||||
|
||||
:::warning
|
||||
|
||||
**There is no gaurantee this will work** but it is better than nothing. You should attempt to re-configure MS to use any of the other solutions before using this one.
|
||||
|
||||
:::
|
||||
|
||||
<img className='image-center' src={require('/img/diagrams/normalized dupes.png').default}/>
|
||||
|
||||
## The Last.fm Problem {#lastfm}
|
||||
|
||||
Last.fm will happily accept almost any scrobble data you throw at it.
|
||||
|
||||
It can then decide, opaquely, to match or modify that scrobble to some more "official" data it has server-side. This could be modifying the artists, title, or album sent in the scrobble. It can also mean modifying the [`mbid` (unique identifier)](https://musicbrainz.org/doc/MusicBrainz_Identifier) or adding what it _thinks_ is the correct one.
|
||||
|
||||
There is nothing in the scrobble history API response from Last.fm that identifies it has done any of this. And [it's not super great at being right about these modifications](https://github.com/FoxxMD/multi-scrobbler/issues/333#issuecomment-3145161339).
|
||||
|
||||
What this means for you, the user, is that this is very plausible scenario:
|
||||
|
||||
* You scrobble `Artist Foo, Artist Bar - My Cool Track` to Last.fm via Pano Scrobbler (outside of Multi-scrobbler)
|
||||
* Last.fm modifies the data to `The Artist Foo - My Cool Track ft. Artist Bar (Album Version)`
|
||||
* You _also_ scrobble `Artist Foo, Artist Bar - My Cool Track` to Multi-scrobbler via Pano LFM endpoint **Source**
|
||||
* MS forwards this to Koito Client as-is
|
||||
* Soon after, MS polls your Last.fm **Source** for new scrobbles and finds `The Artist Foo - My Cool Track ft. Artist Bar (Album Version)`
|
||||
* It queries Koito to see if this was scrobbled
|
||||
* It sees that a scrobble did happen at almost the same time BUT the track titles and artist strings are _just different enough_ that it decides it's not a duplicate
|
||||
* MS forwards the scrobble to Koito and you now have a duplicate
|
||||
|
||||
This behavior is unique to Last.fm. It's truly the **worst** out of all the scrobbler services in terms of preserving your data as-is, or providing a unqiue identifier for your data.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
sidebar_position: 5
|
||||
title: Kitchen Sink
|
||||
description: Kitchen Sink Example
|
||||
---
|
||||
|
||||
@@ -109,9 +109,10 @@ To troubleshoot any errors, and assuming you are using Home Assistant, view the
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenbrainzEndpointSourceConfig" fileContent={ListenbrainzEndpointConfig} name="endpointlz">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
|
||||
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
|
||||
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
|
||||
| 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 |
|
||||
| `LZE_USERNAME` | No | | (Optional) A fake username that will be returned for token validation and on now-playing responses. If none is provided the Source's name is used instead. |
|
||||
</Config>
|
||||
@@ -18,6 +18,8 @@ bind_to_address "any" # or a specific ipv4/v6 address
|
||||
port "6600"
|
||||
```
|
||||
|
||||
Multi-scrobbler requires only the [`read`](https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords) permission in order to monitor your activity.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MPDSourceConfig" fileContent={MPDConfig} name="mpd">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
sidebar_position: 3
|
||||
title: Enhancing Scrobbles
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
@@ -84,6 +84,14 @@ Use the **Last.fm** tab in the [Quickstart Sources](/quickstart/#setup-sources)
|
||||
|
||||
You set up [configurations](/configuration) for one or more [**Sources**](/configuration/sources) and one or more [**Clients**](/configuration/clients). MS monitors all of your configured **Sources**. When new tracks are played by a Source MS grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to forward to.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Diagram</summary>
|
||||
|
||||
<img className='image-center' src={require('/img/diagrams/ms-visual.png').default}/>
|
||||
|
||||
</details>
|
||||
|
||||
### Source
|
||||
|
||||
A [**Source**](/configuration/sources) is a data source that contains information about music you are playing or have listened to, such as: a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
|
||||
|
||||
@@ -221,10 +221,10 @@ After installation see [service.md](service.md) to configure multi-scrobbler to
|
||||
|
||||
### Nodejs
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
Clone this repository somewhere using the latest [git tag](/updating#git) and then install from the working directory.
|
||||
|
||||
```shell
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
git clone --branch <LATEST_RELEASE_TAG> https://github.com/FoxxMD/multi-scrobbler.git
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, to set correct Node version
|
||||
npm install
|
||||
|
||||
@@ -254,9 +254,9 @@ Modify the `lastfm.json` file you created in the [Setup Sources](#setup-sources)
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito config set [`KOITO_ALLOWED_HOSTS`](https://koito.io/reference/configuration/#koito_allowed_hosts) to the IP the multi-scrobbler dashboard is accessible from.
|
||||
|
||||
|
||||
For Koito versions `<=0.1.7`, you must have the environment variable `KOITO_ALLOWED_HOSTS` set correctly in your Koito environment/container. Ensure it is set to the IP address or domain name that you use to access Koito with e.g. `koito.mydomain.com`.
|
||||
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"label": "Updating",
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Way to update Multi-Scrobbler"
|
||||
"type": "doc",
|
||||
"id": "updating/updating"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 'Overview'
|
||||
description: Updating
|
||||
title: Overview
|
||||
description: Updating Multi-Scrobbler
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
@@ -10,38 +10,78 @@ import CodeBlock from '@theme/CodeBlock';
|
||||
|
||||
## Updating
|
||||
|
||||
Currently, multi-scrobbler does not have any databases or dependencies that require additional interaction/consideration when updating.
|
||||
Currently, multi-scrobbler does not have any databases or dependencies that require additional interaction when updating.
|
||||
|
||||
The majority of major/minor version releases introduce only *new* functionality but occasionally may also include breaking changes in behavior or configuration. These changes, and how to update configs accordingly, are detailed in [Github Release Notes](https://github.com/FoxxMD/multi-scrobbler/releases). It is recommended to check this page before upgrading.
|
||||
Any **breaking changes** will be related to [configuration](/configuration) that has been deprecated/changed, or tooling that usually only affects [Local Installations.](/installation#local-installation)
|
||||
|
||||
Alternatively, multi-scrobbler version can be pinned using [git tags](https://github.com/FoxxMD/multi-scrobbler/tags) or docker image tags that match release versions IE `foxxmd/multi-scrobbler:0.9.7`
|
||||
These changes, and how to update configs accordingly, are detailed in [**Github Release Notes**](https://github.com/FoxxMD/multi-scrobbler/releases). It is recommended to check this page before upgrading [minor or major versions.](#versioning)
|
||||
|
||||
### Docker
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
```shell
|
||||
docker image pull foxxmd/multi-scrobbler:latest
|
||||
docker container stop multi-scrobbler
|
||||
docker container rm multi-scrobbler
|
||||
docker run --name multi-scrobbler ...
|
||||
```
|
||||
The last command is the [same as the command used to initialy run multi-scrobbler.](../installation/installation.mdx?runType=docker#docker-usage-example)
|
||||
</TabItem>
|
||||
<Tabs groupId="installType" queryString>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
```shell
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="local" query="Local (Nodejs)">
|
||||
Assuming the [repository was used for initial installation with `git clone`](/installation#local-installation), update by pulling the latest commit:
|
||||
|
||||
```
|
||||
git pull
|
||||
```
|
||||
|
||||
and restart multi-scrobbler.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Nodejs
|
||||
## Versioning
|
||||
|
||||
Assuming the [repository was used for initial installation with `git clone`](../installation/installation.mdx#nodejs), update by pulling the latest commit:
|
||||
Multi-scrobbler versioning uses **semver** without a major version (for now):
|
||||
|
||||
```
|
||||
git pull
|
||||
* **Minor** version bumps (`0.12.2` => `0.13.0`) **may have breaking changes** or include major new behavior
|
||||
* **Patch** version bumps (`0.12.2` => `0.12.3`) may have new behavior or fix bugs, but have no breaking changes. These can be safely upgraded unattended.
|
||||
|
||||
Check the [**Github Release Notes**](https://github.com/FoxxMD/multi-scrobbler/releases) for breaking changes, changelogs, and any migration steps required between minor versions.
|
||||
|
||||
### Git
|
||||
|
||||
The default [`master`](https://github.com/FoxxMD/multi-scrobbler/tree/master/) branch is **not stable**. The goal is to have it be *functional* bleeding-edge development with non-functioning development isolated to pull requests and other branches.
|
||||
|
||||
For a stable release use the latest [git tag](https://github.com/FoxxMD/multi-scrobbler/tags) EX:
|
||||
|
||||
```shell
|
||||
git clone --branch 0.13.0 https://github.com/FoxxMD/multi-scrobbler.git
|
||||
```
|
||||
|
||||
and restart multi-scrobbler.
|
||||
### Docker
|
||||
|
||||
Docker image tags use this pattern:
|
||||
|
||||
* `:latest` is the latest **stable** [release](https://github.com/FoxxMD/multi-scrobbler/releases)
|
||||
* IE If the latest released version was `0.13.0` then `:latest` is also `0.13.0`
|
||||
* `:latest` will *always* be the latest release. Before pulling a new image you should check if the version bump has breaking changes.
|
||||
* `:edge` is built automatically from the [`master` branch](#git)
|
||||
* Pinned semver tags are published alongside [releases](https://github.com/FoxxMD/multi-scrobbler/releases) EX `:0.12.2`
|
||||
|
||||
Additionally, [Pull Requests](https://github.com/FoxxMD/multi-scrobbler/pulls) may have docker images published with tags like `:pr-521`. These are not guaranteed to be stable and should only be used for testing.
|
||||
|
||||
## What Version Am I Using?
|
||||
|
||||
The current version of Multi-Scrobbler can be found in several places:
|
||||
|
||||
* Open the Web Dashboard, the version is displayed next the Multi-Scrobbler logo in the top left corner
|
||||
* On startup the version is immediately printed to logs EX:
|
||||
* `[2026-04-16 09:17:32.428 -0400] INFO : [Init] Version: 0.13.0`
|
||||
* Checking the running docker container or image for the `org.opencontainers.image.version` label
|
||||
|
||||
When running MS from a non-pinned source (like `:edge` tag, a pull request, or git branch) the version will be shown as the **branch** name and **short commit hash** EX
|
||||
|
||||
```
|
||||
master-1c78da79
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Using the above information you can verify if you have pulled and successfully recreated a docker container with a new version. If the version string does not change from the previous version running then you are still running the old container
|
||||
|
||||
:::
|
||||
@@ -2,19 +2,30 @@ import * as Preset from '@docusaurus/preset-classic';
|
||||
import type { Config } from '@docusaurus/types';
|
||||
import * as themes from 'prism-react-renderer';
|
||||
import * as Renderers from './lib/socialCard/ImageRenderers.js';
|
||||
import path from 'node:path';
|
||||
//import sidebars from './sidebars';
|
||||
|
||||
let baseSite = 'https://foxxmd.github.io';
|
||||
// https://docs.netlify.com/build/configure-builds/environment-variables/#deploy-urls-and-metadata
|
||||
const netlifyDeploy = process.env.DEPLOY_PRIME_URL;
|
||||
if(netlifyDeploy !== undefined && netlifyDeploy !== null && netlifyDeploy.trim() !== '') {
|
||||
baseSite = netlifyDeploy;
|
||||
} else {
|
||||
const baseSiteEnv = process.env.BASE_SITE;
|
||||
if(baseSiteEnv !== undefined && baseSiteEnv !== null && baseSiteEnv.trim() !== '') {
|
||||
baseSite = baseSiteEnv;
|
||||
let baseSite: string = 'https://foxxmd.github.io';
|
||||
|
||||
const baseUrlEnvs = [
|
||||
// To build with base URL via normal docker build with build-args/env
|
||||
// or if specified, to override the subsequent set ENVs
|
||||
process.env.BASE_SITE,
|
||||
// To build with base URL set via netlify CI
|
||||
// https://docs.netlify.com/build/configure-builds/environment-variables/#deploy-urls-and-metadata
|
||||
process.env.DEPLOY_PRIME_URL,
|
||||
// To build with base URL set via cloudflare pages CI
|
||||
// https://developers.cloudflare.com/pages/configuration/build-configuration/#environment-variables
|
||||
process.env.CF_PAGES_URL
|
||||
];
|
||||
|
||||
for(const baseUrlVal of baseUrlEnvs) {
|
||||
if(baseUrlVal !== undefined && baseUrlVal !== null && baseUrlVal.trim() !== '') {
|
||||
baseSite = baseUrlVal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log(`Base URL => ${baseSite}`);
|
||||
|
||||
const config: Config = {
|
||||
future: {
|
||||
@@ -69,8 +80,15 @@ const config: Config = {
|
||||
routeBasePath: '/',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl:
|
||||
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
editUrl: (params: {
|
||||
version: string;
|
||||
versionDocsDirPath: string;
|
||||
docPath: string;
|
||||
permalink: string;
|
||||
locale: string;
|
||||
}) => {
|
||||
return `${path.join('https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
}
|
||||
},
|
||||
// blog: {
|
||||
// showReadingTime: true,
|
||||
|
||||
@@ -2,9 +2,6 @@ import React, { Fragment } from "react"
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import Admonition from '@theme/Admonition';
|
||||
import ErrorBoundary from "@docusaurus/ErrorBoundary"
|
||||
import Error from "@theme/Error"
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import error = Simulate.error;
|
||||
import json5 from 'json5';
|
||||
|
||||
export interface AIOProps {
|
||||
|
||||
@@ -2,9 +2,6 @@ import React, { Fragment, useMemo } from "react"
|
||||
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
|
||||
import Admonition from '@theme/Admonition';
|
||||
import ErrorBoundary from "@docusaurus/ErrorBoundary"
|
||||
import Error from "@theme/Error"
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import error = Simulate.error;
|
||||
import json5 from 'json5';
|
||||
import FileExample, { FileProps } from "./FileExample";
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, { Fragment, useMemo } from "react"
|
||||
import CodeBlock, { Props as CodeBlockProps } from '@theme/CodeBlock';
|
||||
import Admonition from '@theme/Admonition';
|
||||
import ErrorBoundary from "@docusaurus/ErrorBoundary"
|
||||
import Error from "@theme/Error"
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { useTypedLocalStorage } from "./useLocalStorage";
|
||||
import ButtonGroup from "./ButtonGroup";
|
||||
import EditorFetch from '@site/src/components/Schema/SchemaEditorFetch';
|
||||
|
||||
@@ -90,4 +90,11 @@ details[class^='details_'].alert--info {
|
||||
--ifm-spacing-vertical: 0.5rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** https://stackoverflow.com/a/79808430/1469797 */
|
||||
img.image-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.2",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"build:backend": "tsc -p src/backend && npm run -s schema:app",
|
||||
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
|
||||
"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:install": "cd docsite && npm install --no-audit",
|
||||
"docs:start": "cd docsite && npm start",
|
||||
"docs:build": "npm run -s schema:docs && cd docsite && npm run build",
|
||||
"docs:netlify": "npm run -s docs:install && npm run -s docs:build",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.14.0",
|
||||
"npm": ">=11.12.1"
|
||||
"npm": "^11.12.1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -52,15 +52,8 @@
|
||||
"@atcute/identity-resolver-node": "^1.0.3",
|
||||
"@atproto/api": "^0.18.0",
|
||||
"@atproto/oauth-client-node": "^0.3.10",
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"@chakra-ui/react": "^3.34.0",
|
||||
"@curvenote/ansi-to-react": "^7.0.0",
|
||||
"@donedeal0/superdiff": "^1.1.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@ewanc26/tid": "^1.0.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@foxxmd/chromecast-client": "^1.0.4",
|
||||
"@foxxmd/get-version": "^0.0.3",
|
||||
"@foxxmd/logging": "^0.2.6",
|
||||
@@ -71,25 +64,19 @@
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@keyv/valkey": "^1.0.8",
|
||||
"@lukehagar/plexjs": "^0.39.0",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
"@svrooij/sonos": "^2.5.0",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/express-serve-static-core": "^4.19.6",
|
||||
"@xhayper/discord-rpc": "^1.3.0",
|
||||
"abort-controller-x": "^0.5.0",
|
||||
"address": "^1.2.2",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"avahi-browse": "^1.1.4",
|
||||
"better-sse": "^0.8.0",
|
||||
"body-parser": "^2.2.2",
|
||||
"cacheable": "^1.10.4",
|
||||
"castv2": "^0.1.10",
|
||||
"clone": "^2.1.2",
|
||||
"clsx": "^2.0.0",
|
||||
"common-tags": "^1.8.2",
|
||||
"compare-versions": "^4.1.2",
|
||||
"concat-stream": "^2.0.0",
|
||||
@@ -98,9 +85,9 @@
|
||||
"dbus-ts": "^0.0.7",
|
||||
"discord.js": "^14.26.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.22.1",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-equals": "^6.0.0",
|
||||
"fixed-size-list": "^0.3.0",
|
||||
"flat-cache": "^6.1.13",
|
||||
"formidable": "^3.5",
|
||||
@@ -114,16 +101,14 @@
|
||||
"json-diff-ts": "^5.0.0-alpha.2",
|
||||
"json5": "^2.2.3",
|
||||
"jsondiffpatch": "^0.7.3",
|
||||
"jsondiffpatch-react": "^1.0.8",
|
||||
"keyv": "^5.5.0",
|
||||
"kodi-api": "^0.2.1",
|
||||
"lastfm-ts-api": "^2.6.0",
|
||||
"merge-error-cause": "^5.0.2",
|
||||
"mopidy": "^1.3.0",
|
||||
"mpd-api": "^1.1.2",
|
||||
"mpc-js": "^2.1.1",
|
||||
"musicbrainz-api": "^0.27.0",
|
||||
"nanoid": "^3.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-object-hash": "^3.1.1",
|
||||
"normalize-url": "^8.0.1",
|
||||
"ntfy": "^1.15.2",
|
||||
@@ -134,41 +119,40 @@
|
||||
"passport-deezer": "^0.2.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"round-robin-js": "^3.0.10",
|
||||
"sass": "^1.93.3",
|
||||
"serialize-error": "^13.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"spotify-web-api-node": "^5.0.2",
|
||||
"superagent": "^8.0.9",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"tough-cookie": "^5.1.2",
|
||||
"ts-json-schema-generator": "^2.3.0",
|
||||
"tsx": "^4.7.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typeson": "^9.0.4",
|
||||
"typeson-registry": "^11.1.1",
|
||||
"undici": "7.24.6",
|
||||
"vite-express": "^0.16.0",
|
||||
"vite-express": "^0.22.1",
|
||||
"vlc-client": "^1.1.1",
|
||||
"xml2js": "0.6.2",
|
||||
"youtubei.js": "^15.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/react": "^3.34.0",
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@curvenote/ansi-to-react": "^7.0.0",
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^8.56.0",
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"@storybook/addon-a11y": "10.1.11",
|
||||
"@storybook/addon-docs": "10.1.11",
|
||||
"@storybook/react-vite": "10.1.11",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tsconfig/node18": "^1.0.1",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
@@ -176,8 +160,9 @@
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-serve-static-core": "^5.1.1",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/jscodeshift": "^0.11.6",
|
||||
@@ -193,20 +178,34 @@
|
||||
"@types/superagent": "^8.1.9",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^8.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"git-cliff": "^2.12.0",
|
||||
"jsondiffpatch-react": "^1.0.8",
|
||||
"mocha": "^10.3.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"msw": "^2.12.10",
|
||||
"neotraverse": "^0.6.18",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemon": "^3.0.3",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-virtuoso": "^4.18.4",
|
||||
"sass": "^1.99.0",
|
||||
"shiki": "^4.0.2",
|
||||
"sinon": "^21.0.2",
|
||||
"storybook": "10.2.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"ts-essentials": "^10.1.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^7.0.1",
|
||||
@@ -228,6 +227,9 @@
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
},
|
||||
"musicbrainz-api": {
|
||||
"rate-limit-threshold": "0.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import {
|
||||
cacheFunctions,
|
||||
} from "@foxxmd/regex-buddy-core";
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { LifecycleStep, PlayData, PlayObject, TransformResult } from "../../core/Atomic.js";
|
||||
import { buildPlayHumanDiffable, buildTrackString } from "../../core/StringUtils.js";
|
||||
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
|
||||
@@ -28,6 +26,7 @@ import { MSCache } from "./Cache.js";
|
||||
import { diffObjects, diffObjectsConsoleOutput, patchObject } from "../../core/DataUtils.js";
|
||||
import clone from "clone";
|
||||
import { loggerNoop } from "./MaybeLogger.js";
|
||||
import { objectsEqual } from "../utils/DataUtils.js";
|
||||
|
||||
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
|
||||
|
||||
@@ -167,9 +166,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean | 'all') => {
|
||||
|
||||
|
||||
const asyncId = nanoid(6);
|
||||
let logger = childLogger(this.logger, ['Play Transform', hookType, asyncId]);
|
||||
let logger: Logger;
|
||||
|
||||
try {
|
||||
let hook: StageConfig[];
|
||||
@@ -193,6 +190,9 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
return play;
|
||||
}
|
||||
|
||||
const asyncId = nanoid(6);
|
||||
logger = childLogger(this.logger, ['Play Transform', hookType, asyncId]);
|
||||
|
||||
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
|
||||
|
||||
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
|
||||
@@ -204,7 +204,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
logger.debug(`Transform start for => ${buildTrackString(play)}`);
|
||||
let transformedPlay: PlayObject = clone(play);
|
||||
let cacheOk: boolean = true;
|
||||
let cacheOk: boolean = true,
|
||||
generateSteps = true;
|
||||
const steps: LifecycleStep[] = [];
|
||||
|
||||
const opts = {
|
||||
@@ -213,22 +214,29 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
}
|
||||
|
||||
if(cachedSteps !== undefined) {
|
||||
generateSteps = false;
|
||||
// don't re-cache cached steps
|
||||
cacheOk = false;
|
||||
// only patch play if steps didn't end in a failure that we are okay with returning partial from
|
||||
let shouldTransform = true;
|
||||
const lastCachedStep = cachedSteps[cachedSteps.length - 1];
|
||||
if(lastCachedStep.error !== undefined && lastCachedStep.flowKnownState !== 'skip' && !lastCachedStep.returnPartial) {
|
||||
shouldTransform = false;
|
||||
}
|
||||
|
||||
for(const s of cachedSteps) {
|
||||
steps.push({...s, cached: true});
|
||||
if(shouldTransform && s.patch !== undefined) {
|
||||
transformedPlay.data = patchObject(transformedPlay.data, s.patch); // jdiff.patch(clone(transformedPlay.data),s.patch);
|
||||
try {
|
||||
// only patch play if steps didn't end in a failure that we are okay with returning partial from
|
||||
let shouldTransform = true;
|
||||
const lastCachedStep = cachedSteps[cachedSteps.length - 1];
|
||||
if(lastCachedStep.error !== undefined && lastCachedStep.flowKnownState !== 'skip' && !lastCachedStep.returnPartial) {
|
||||
shouldTransform = false;
|
||||
}
|
||||
|
||||
for(const s of cachedSteps) {
|
||||
steps.push({...s, cached: true});
|
||||
if(shouldTransform && s.patch !== undefined) {
|
||||
transformedPlay.data = patchObject(transformedPlay.data, s.patch); // jdiff.patch(clone(transformedPlay.data),s.patch);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(new Error('Error occurred while trying to use cached steps. Falling back to generating fresh transform steps', {cause: e}));
|
||||
generateSteps = true;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if(generateSteps) {
|
||||
for(const hookItem of hook) {
|
||||
const [step, stepPlay] = await this.generateStepFromStage(transformedPlay, hookItem, hookType, opts);
|
||||
steps.push(step);
|
||||
@@ -317,7 +325,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
return transformedPlay;
|
||||
} catch (e) {
|
||||
logger.warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
|
||||
const err = new Error(`Unexpected error occurred, returning original play.`, {cause: e});
|
||||
if(logger === undefined) {
|
||||
this.logger.warn(err);
|
||||
} else {
|
||||
logger.warn(err);
|
||||
}
|
||||
return play;
|
||||
}
|
||||
}
|
||||
@@ -337,7 +350,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
const stepName = `${hookType} - ${hookItem.type} - ${hookItem.name}`
|
||||
const existingStepIndex = playTruth.meta.lifecycle.steps.findIndex(x => x.name === stepName && x.source === this.getIdentifier());
|
||||
const step: LifecycleStep = existingStepIndex !== -1 ? playTruth.meta.lifecycle.steps[existingStepIndex] : {
|
||||
const step: LifecycleStep = existingStepIndex !== -1 && playTruth.meta.lifecycle.steps[existingStepIndex] !== undefined ? playTruth.meta.lifecycle.steps[existingStepIndex] : {
|
||||
name: stepName,
|
||||
source: this.getIdentifier(),
|
||||
}
|
||||
@@ -401,7 +414,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
} else {
|
||||
step.flowResult = onSuccess;
|
||||
|
||||
if (!deepEqual(playTruth.data, newTransformedPlay.data)) {
|
||||
if (!objectsEqual(playTruth.data, newTransformedPlay.data)) {
|
||||
const o = JSON.parse(JSON.stringify(playTruth.data));
|
||||
const t = JSON.parse(JSON.stringify(newTransformedPlay.data));
|
||||
const patch = diffObjects(o, t); // jdiff.diff(o, t);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import {truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
|
||||
import { hasUpstreamError } from "./errors/UpstreamError.js";
|
||||
import play = Simulate.play;
|
||||
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
|
||||
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError, TransformRulesError } from "./errors/MSErrors.js";
|
||||
import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Logger } from '@foxxmd/logging';
|
||||
import { Dayjs, ManipulateType } from "dayjs";
|
||||
import { Request, Response } from "express";
|
||||
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
|
||||
import { type NextFunction, type ParamsDictionary, type Query } from "express-serve-static-core";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { isPlayObject, PlayMetaLifecycleless, PlayObject, PlayObjectLifecycleless, UnixTimestamp } from "../../../core/Atomic.js";
|
||||
import TupleMap from "../TupleMap.js";
|
||||
@@ -94,7 +94,8 @@ export interface PlayerStateDataMaybePlay {
|
||||
play?: PlayObject
|
||||
status?: ReportedPlayerStatus
|
||||
position?: number
|
||||
timestamp?: Dayjs
|
||||
stateUpdatedAt?: Dayjs
|
||||
playUpdatedAt?: Dayjs
|
||||
}
|
||||
|
||||
export const asPlayerStateData = (obj: object): obj is PlayerStateData => asPlayerStateDataMaybePlay(obj) && 'play' in obj && isPlayObject(obj.play)
|
||||
|
||||
@@ -13,14 +13,23 @@ export interface ListenbrainzEndpointData extends CommonSourceData {
|
||||
*
|
||||
* 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
|
||||
slug?: string
|
||||
|
||||
/**
|
||||
* 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
|
||||
token?: string
|
||||
|
||||
/**
|
||||
* The listenbrainz "username" to associate with this Source
|
||||
*
|
||||
* Will be returned in validate-token responses and used to determine a response for playing-now, if your client supports it
|
||||
*
|
||||
* If no value is configured then the Source's name will be used
|
||||
*/
|
||||
username?: string
|
||||
}
|
||||
|
||||
export interface ListenbrainzEndpointSourceConfig extends CommonSourceConfig {
|
||||
|
||||
@@ -39,33 +39,4 @@ export interface MPDSourceAIOConfig extends MPDSourceConfig {
|
||||
type: 'mpd'
|
||||
}
|
||||
|
||||
export type PlayerState = 'play' | 'stop' | 'pause';
|
||||
|
||||
export interface StatusResponse {
|
||||
state: PlayerState
|
||||
/**
|
||||
* Position within the current song in seconds
|
||||
* */
|
||||
elapsed?: number
|
||||
/**
|
||||
* Duration of the current song in seconds
|
||||
* */
|
||||
duration?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CurrentSongResponse {
|
||||
file: string
|
||||
time: number
|
||||
name?: string
|
||||
performer?: string
|
||||
artist?: string
|
||||
album?: string
|
||||
albumartist?: string
|
||||
title?: string
|
||||
musicbrainz_albumartistid?: string
|
||||
musicbrainz_albumid?: string
|
||||
musicbrainz_artistid?: string
|
||||
musicbrainz_releasetrackid?: string
|
||||
musicbrainz_trackid?: string
|
||||
}
|
||||
export type PlayerState = 'play' | 'stop' | 'pause';
|
||||
@@ -214,7 +214,7 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr
|
||||
|
||||
for(const artist of play.data.artists) {
|
||||
|
||||
const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist)})).filter(x => x !== undefined);
|
||||
const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist)})).filter(x => x.res !== undefined);
|
||||
if(matchedIgnoreArtists.length > 0) {
|
||||
logger.debug(`Will not parse artist because it matched an ignore regex:\n${matchedIgnoreArtists.map(x => `Reg: ${x.reg} => ${x.res.match}`).join('\n')}`);
|
||||
artists.push(artist);
|
||||
|
||||
@@ -16,7 +16,8 @@ import { LastFMUser, LastFMAuth, LastFMTrack, LastFMUserGetRecentTracksResponse,
|
||||
import clone from 'clone';
|
||||
import { IncomingMessage } from "http";
|
||||
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js";
|
||||
import { ScrobbleSubmitError } from "../errors/MSErrors.js";
|
||||
import { ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.js";
|
||||
import { redactString } from "@foxxmd/redact-string";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
@@ -43,8 +44,8 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
user?: string;
|
||||
declare config: LastfmData;
|
||||
sessionKey?: string;
|
||||
lastRefreshed?: number
|
||||
|
||||
urlBase: string;
|
||||
path: string;
|
||||
upstreamName: string = 'Last.fm';
|
||||
|
||||
@@ -141,23 +142,31 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
|
||||
getAuthUrl = () => {
|
||||
|
||||
if(this.url.url.host === LASTFM_HOST) {
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`;
|
||||
const isLastFm = this.url.url.host === LASTFM_HOST;
|
||||
let authUrl: string;
|
||||
if(isLastFm) {
|
||||
this.logger.debug('Detected last.fm host');
|
||||
authUrl = `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`;
|
||||
} else {
|
||||
this.logger.debug('Detected non-last.fm host (probably libre.fm)');
|
||||
const aUrl = new URL(this.url.url.toString());
|
||||
aUrl.search = `?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`;
|
||||
aUrl.pathname = '/api/auth';
|
||||
authUrl = aUrl.toString();
|
||||
}
|
||||
|
||||
const aUrl = new URL(this.url.url.toString());
|
||||
aUrl.search = `?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`;
|
||||
aUrl.pathname = '/api/auth';
|
||||
return aUrl.toString();
|
||||
const redactedUrl = authUrl.replace(this.config.apiKey, redactString(this.config.apiKey, 6));
|
||||
this.logger.debug(`Generated auth url: ${redactedUrl}`);
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
authenticate = async (token: any) => {
|
||||
|
||||
this.logger.debug(`Recieved token from auth callback: ${redactString(token, 10)}`);
|
||||
const auth = new LastFMAuth(this.config.apiKey, this.config.secret, undefined, {
|
||||
hostname: this.url.url.host,
|
||||
path: this.url.url.pathname
|
||||
});
|
||||
|
||||
this.logger.debug('Trying to get session key from last.fm...');
|
||||
const sessionRes = await auth.getSession({token});
|
||||
const {
|
||||
session: {
|
||||
@@ -165,18 +174,23 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
name, // username
|
||||
} = {}
|
||||
} = sessionRes;
|
||||
this.lastRefreshed = dayjs().unix();
|
||||
this.logger.debug(`Created session for user ${name}! Session key: ${redactString(sessionKey, 6)} | Refreshed At ${this.lastRefreshed}`);
|
||||
this.sessionKey = sessionKey;
|
||||
this.user = name;
|
||||
this.user = name;
|
||||
|
||||
this.initApi();
|
||||
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
sessionKey,
|
||||
name,
|
||||
lastRefreshed: this.lastRefreshed
|
||||
}));
|
||||
this.logger.trace(`Wrote session data to ${this.workingCredsPath}`);
|
||||
}
|
||||
|
||||
protected initApi = () => {
|
||||
this.logger.debug(`Creating new API instances with: API Key ${redactString(this.config.apiKey, 6)} | Secret: ${redactString(this.config.secret, 6)} | Session Key ${redactString(this.sessionKey, 6)} | Host ${this.url.url.host} | Path ${this.url.url.pathname}`);
|
||||
this.userApi = new LastFMUser(this.config.apiKey, this.config.secret, this.sessionKey, {
|
||||
hostname: this.url.url.host,
|
||||
path: this.url.url.pathname
|
||||
@@ -190,12 +204,19 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
initialize = async (): Promise<true> => {
|
||||
|
||||
try {
|
||||
this.logger.debug(`Trying to load credentials from file path: ${this.workingCredsPath}`);
|
||||
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false, interpolateEnvs: false});
|
||||
const {sessionKey, name} = creds || {};
|
||||
const {sessionKey, name, lastRefreshed} = creds || {};
|
||||
this.sessionKey = sessionKey;
|
||||
this.user = name;
|
||||
if(lastRefreshed !== undefined) {
|
||||
this.lastRefreshed = lastRefreshed;
|
||||
}
|
||||
if(this.sessionKey !== undefined) {
|
||||
this.logger.trace('Found session key during initialization, initing api instances');
|
||||
this.initApi();
|
||||
} else {
|
||||
this.logger.trace('No session key found during initialization, not initing api instances');
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -218,16 +239,20 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
if (this.sessionKey === undefined) {
|
||||
this.logger.warn('No session key found. User interaction for authentication required.');
|
||||
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
|
||||
throw new Error('No session key found. User interaction for authentication required.');
|
||||
throw new SimpleError('No session key found. User interaction for authentication required.');
|
||||
}
|
||||
try {
|
||||
// existing lastfm clients are ok with getting user from getInfo
|
||||
// but libre throws an error
|
||||
// so, if not libre allow empty user and set user from response to comply with new librefm implementation/refactor
|
||||
const infoPayload: Record<string, any> = {};
|
||||
if(this.urlBase !== LASTFM_HOST) {
|
||||
const infoPayload: Record<string, any> = {
|
||||
sk: this.sessionKey,
|
||||
};
|
||||
if(this.url.url.host !== LASTFM_HOST) {
|
||||
this.logger.debug(`Adding ${this.user} to getInfo payload due to non-last.fm host`);
|
||||
infoPayload.user = this.user;
|
||||
}
|
||||
this.logger.debug(`Using sk:${redactString(this.sessionKey, 6)} in getInfo payload for auth test...`);
|
||||
const resp = await this.callApi<LastFMUserGetInfoResponse>(() => this.userApi.getInfo(infoPayload));
|
||||
if(this.user === undefined) {
|
||||
this.user = resp.user.name;
|
||||
@@ -266,6 +291,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
'@attr': {
|
||||
total,
|
||||
totalPages,
|
||||
perPage,
|
||||
page
|
||||
}
|
||||
} = {},
|
||||
@@ -306,7 +332,21 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { data: plays, meta: { ...fetchOptions, total: parseInt(total, 10), more: fetchOptions.cursor < parseInt(totalPages, 10) } };
|
||||
let totalNum: number | undefined;
|
||||
// libre.fm does not return total, only perPage and totalPages
|
||||
if(total !== undefined) {
|
||||
if(typeof total === 'string') {
|
||||
totalNum = parseInt(total, 10);
|
||||
} else {
|
||||
totalNum = total;
|
||||
}
|
||||
} else if(perPage !== undefined && totalPages !== undefined) {
|
||||
const pp = typeof perPage === 'string' ? parseInt(perPage, 10) : perPage;
|
||||
const tp = typeof totalPages === 'string' ? parseInt(totalPages, 10): totalPages;
|
||||
totalNum = pp * tp;
|
||||
}
|
||||
|
||||
return { data: plays, meta: { ...fetchOptions, total: totalNum, more: fetchOptions.cursor < parseInt(totalPages, 10) } };
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -110,13 +110,24 @@ export class DiscordAbstractClient extends AbstractApiClient {
|
||||
|
||||
if(art === artworkDefaultUrl) {
|
||||
const play = isPlayObject(data) ? data : data.play;
|
||||
if(play.data.meta?.brainz?.album !== undefined) {
|
||||
const albumArt = await this.covertArtApi.getCoverThumb(play.data.meta?.brainz?.album, {size: 250});
|
||||
if(albumArt !== undefined) {
|
||||
art = albumArt;
|
||||
if(play.data.meta?.brainz?.album !== undefined || play.data.meta?.brainz?.releaseGroup !== undefined) {
|
||||
let usedCAA = false;
|
||||
if(play.data.meta?.brainz?.album !== undefined) {
|
||||
const albumArt = await this.covertArtApi.getCoverThumb(play.data.meta?.brainz?.album, 'release', {size: 250});
|
||||
if(albumArt !== undefined) {
|
||||
art = albumArt;
|
||||
usedCAA = true;
|
||||
}
|
||||
}
|
||||
} else if(isDebugMode()) {
|
||||
this.logger.debug('No brainz Release MBID to use for cover art archive');
|
||||
if(!usedCAA && play.data.meta?.brainz?.releaseGroup !== undefined) {
|
||||
const albumArt = await this.covertArtApi.getCoverThumb(play.data.meta?.brainz?.releaseGroup, 'release-group', {size: 250});
|
||||
if(albumArt !== undefined) {
|
||||
art = albumArt;
|
||||
usedCAA = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.trace('No brainz Release MBID to use for cover art archive');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export const playStateToActivityData = (data: SourcePlayerObj, opts: { useArt?:
|
||||
|
||||
let activityName = capitalize(play.meta?.musicService ?? play.meta?.mediaPlayerName ?? play.meta?.source ?? 'music')
|
||||
|
||||
// discord requires all fields to be at least 2 characters long or else it returns this error
|
||||
//
|
||||
// "code": 4000,
|
||||
// "message": "child \"activity\" fails because [child \"details\" fails because [\"details\" length must be at least 2 characters long]]"
|
||||
//
|
||||
// so we pad all fields with zero-width space to get around this
|
||||
|
||||
const activity: ActivityData = removeUndefinedKeys<ActivityData>({
|
||||
// https://docs.discord.com/developers/events/gateway-events#activity-object
|
||||
@@ -38,12 +44,13 @@ export const playStateToActivityData = (data: SourcePlayerObj, opts: { useArt?:
|
||||
// https://docs.discord.com/developers/events/gateway-events#activity-object
|
||||
statusDisplayType: 1, // state
|
||||
name: activityName,
|
||||
details: play.data.track,
|
||||
state: play.data.artists !== undefined && play.data.artists.length > 0 ? play.data.artists.join(' / ') : undefined,
|
||||
|
||||
details: play.data.track.padEnd(2,'\u200B'),
|
||||
state: play.data.artists !== undefined && play.data.artists.length > 0 ? play.data.artists.map(x => x.padEnd(2, '\u200B')).join(' / ') : undefined,
|
||||
// https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets
|
||||
// https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image
|
||||
assets: {
|
||||
largeText: play.data.album
|
||||
largeText: play.data.album.padEnd(2, '\u200B')
|
||||
},
|
||||
createdAt: dayjs().unix()
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ export class KoitoApiClient extends AbstractApiClient implements PaginatedTimeRa
|
||||
let allowedHint = '';
|
||||
if(e.cause !== undefined && 'response' in e.cause) {
|
||||
if(e.cause.response.status === 403) {
|
||||
allowedHint = ` HINT: 403 usually means Koito env KOITO_ALLOWED_HOSTS is not configured correctly to allowed requests from multi-scrobbler. Check Koito logs for warnings.`
|
||||
allowedHint = ` HINT: 403 usually means Koito env KOITO_ALLOWED_HOSTS is not configured correctly. Check Koito logs for warnings.`
|
||||
}
|
||||
}
|
||||
throw new Error(`A server exists at ${this.url.url.hostname}:${this.url.port} but is not responding to API calls as expected.${allowedHint}`, { cause: e });
|
||||
@@ -158,7 +158,7 @@ export class KoitoApiClient extends AbstractApiClient implements PaginatedTimeRa
|
||||
|
||||
let resp: ListensResponse;
|
||||
try {
|
||||
resp = await this.getUserListens({...params, page: params.cursor});
|
||||
resp = await this.getUserListens({...params, page: params.cursor});
|
||||
} catch (e) {
|
||||
throw new Error('Error occurred while getting Koito paginated listens', { cause: e });
|
||||
}
|
||||
@@ -239,4 +239,4 @@ export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {
|
||||
}
|
||||
}
|
||||
return baseFormatPlayObj(obj, play);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,12 @@ export interface ListenPayload {
|
||||
track_metadata: TrackPayload;
|
||||
}
|
||||
|
||||
export interface PlayingNowPayload {
|
||||
playing_now: true
|
||||
track_metadata: TrackPayload;
|
||||
}
|
||||
|
||||
|
||||
// this is what is sent to submit-listens
|
||||
export interface SubmitPayload {
|
||||
listen_type: ListenType;
|
||||
|
||||
@@ -67,7 +67,7 @@ export class CoverArtApiClient extends AbstractApiClient {
|
||||
return 'API - CoverArtArchive';
|
||||
}
|
||||
|
||||
getCoverThumb = async (mbid: string, opt: ThumbOptions = {}): Promise<string | undefined> => {
|
||||
getCoverThumb = async (mbid: string, mbidType: 'release' | 'release-group', opt: ThumbOptions = {}): Promise<string | undefined> => {
|
||||
const {
|
||||
type = 'front',
|
||||
size
|
||||
@@ -77,7 +77,7 @@ export class CoverArtApiClient extends AbstractApiClient {
|
||||
throw new Error(`Thumb size given (${size}) is not valid. Must be one of: ${THUMB_SIZES.join(' | ')}`);
|
||||
}
|
||||
const thumbParams = `${type}${size !== undefined ? `-${size}` : ''}`;
|
||||
const cacheKey = `albumart-${mbid}-${thumbParams}`;
|
||||
const cacheKey = `albumart-${mbidType}-${mbid}-${thumbParams}`;
|
||||
const cachedArt = await this.cache.get<string | false>(cacheKey);
|
||||
if (cachedArt !== undefined) {
|
||||
if(cachedArt === false) {
|
||||
@@ -88,7 +88,7 @@ export class CoverArtApiClient extends AbstractApiClient {
|
||||
let result = undefined,
|
||||
err: Error;
|
||||
|
||||
const url = joinedUrl(this.baseUrl, `/release/${mbid}/${thumbParams}`);
|
||||
const url = joinedUrl(this.baseUrl, `/${mbidType}/${mbid}/${thumbParams}`);
|
||||
|
||||
try {
|
||||
const resp = await this.coverThumbRequest(url.toString());
|
||||
@@ -103,7 +103,7 @@ export class CoverArtApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
if(result === false) {
|
||||
this.logger.debug(`No front album art found for release ${mbid}`);
|
||||
this.logger.debug(`No front album art found for ${mbidType} ${mbid}`);
|
||||
await this.cache.set(cacheKey, false, '1hr');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { parseVersion } from "./version.js";
|
||||
import { initServer } from "./server/index.js";
|
||||
import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js";
|
||||
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
|
||||
import { isDebugMode, parseBool, retry, sleep } from "./utils.js";
|
||||
import { isDebugMode, parseBool, retry } from "./utils.js";
|
||||
import { readJson } from './utils/DataUtils.js';
|
||||
//import { createVegaGenerator } from './utils/SchemaUtils.js';
|
||||
import ScrobbleClients from './scrobblers/ScrobbleClients.js';
|
||||
|
||||
@@ -229,7 +229,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
if(preCompare.length > 0) {
|
||||
let pcInits: number[] = [0],
|
||||
pcMaxStagger: number[] = [];
|
||||
for(const hook of this.transformRules.preCompare) {
|
||||
for(const hook of preCompare) {
|
||||
const t = this.transformManager.getTransformerByStage({type: hook.type, name: hook.name});
|
||||
pcInits.push(t.staggerOpts?.initialInterval ?? 0);
|
||||
pcMaxStagger.push(t.staggerOpts?.maxRandomStagger ?? 0)
|
||||
@@ -240,7 +240,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
if(existing.length > 0) {
|
||||
let eInits: number[] = [0],
|
||||
eMaxStagger: number[] = [];
|
||||
for(const hook of this.transformRules.postCompare) {
|
||||
for(const hook of existing) {
|
||||
const t = this.transformManager.getTransformerByStage({type: hook.type, name: hook.name});
|
||||
eInits.push(t.staggerOpts?.initialInterval ?? 0);
|
||||
eMaxStagger.push(t.staggerOpts?.maxRandomStagger ?? 0)
|
||||
@@ -270,6 +270,19 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
this.emitEvent('notify', payload);
|
||||
}
|
||||
|
||||
public nowPlayingSourceAllowed(source: string) {
|
||||
if(!this.supportsNowPlaying || !this.nowPlayingEnabled) {
|
||||
return false;
|
||||
}
|
||||
const {
|
||||
options = {},
|
||||
} = this.config;
|
||||
if('nowPlaying' in options && Array.isArray(options.nowPlaying)) {
|
||||
return options.nowPlaying.map(x => x.toLocaleLowerCase()).includes(source.toLocaleLowerCase());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected initializeNowPlaying() {
|
||||
|
||||
if (this.supportsNowPlaying) {
|
||||
@@ -377,12 +390,21 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
if (this.nowPlayingLastPlay !== undefined) {
|
||||
|
||||
for (const [platform, data] of plays) {
|
||||
if (platform === this.nowPlayingLastPlay.platformId) {
|
||||
if (platform === this.nowPlayingLastPlay.platformId
|
||||
// only keep using sticky platform if it hasn't gone stale/orphaned
|
||||
&& (!(data.player.status?.stale ?? false) && !(data.player.status?.orphaned ?? false))) {
|
||||
return data.player;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prefer players that are not stale/orphaned
|
||||
let preferredPlays: typeof plays = plays.filter(([platform, data]) => !(data.player.status?.stale ?? false) && !(data.player.status?.orphaned ?? false));
|
||||
if(preferredPlays.length === 0) {
|
||||
// but if there are none of these then just use whatever players are on-hand
|
||||
preferredPlays = plays;
|
||||
}
|
||||
|
||||
// otherwise sort platform alphabetically and take first
|
||||
plays.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
return plays[0][1].player;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js
|
||||
|
||||
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
api: ListenbrainzApiClient;d
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
getScrobblesForTimeRange: TimeRangeListensFetcher
|
||||
|
||||
@@ -497,6 +497,22 @@ ${sources.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
getPlayingNow = (source: string, scrobbleTo: string[]): PlayObject[] => {
|
||||
let playingNow = [];
|
||||
for (const client of this.clients) {
|
||||
if(!client.supportsNowPlaying || !client.nowPlayingEnabled) {
|
||||
continue;
|
||||
}
|
||||
if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
|
||||
continue;
|
||||
}
|
||||
if(client.nowPlayingSourceAllowed(source) && client.nowPlayingLastPlay !== undefined) {
|
||||
playingNow.push(client.nowPlayingLastPlay.play);
|
||||
}
|
||||
}
|
||||
return playingNow.filter(x => x !== undefined);
|
||||
}
|
||||
|
||||
scrobble = async (data: (PlayObject | PlayObject[]), options: {forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string} = {}) => {
|
||||
const playObjs = Array.isArray(data) ? data : [data];
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { LogDataPretty, Logger, LogLevel } from "@foxxmd/logging";
|
||||
import { Express } from 'express';
|
||||
import bsseDef from 'better-sse';
|
||||
import bodyParser from "body-parser";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
@@ -55,7 +55,7 @@ const getLogs = (minLevel: number, limit: number = maxBufferSize, sort: 'asc' |
|
||||
return allLogs.flat(1).sort((a, b) => a.time - b.time).slice(0, limit);
|
||||
}
|
||||
|
||||
export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
for(const level of Object.keys(logger.levels.labels)) {
|
||||
output[level] = new FixedSizeList<LeveledLogData>(maxBufferSize);
|
||||
}
|
||||
@@ -151,21 +151,21 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
|
||||
setupDeezerRoutes(app, logger, scrobbleSources);
|
||||
setupWebscrobblerRoutes(app, logger, scrobbleSources);
|
||||
setupLZEndpointRoutes(app, logger, scrobbleSources);
|
||||
setupLZEndpointRoutes(app, logger, scrobbleSources, scrobbleClients);
|
||||
setupLastfmEndpointRoutes(app, logger, scrobbleSources);
|
||||
setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
|
||||
|
||||
app.putAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
|
||||
app.put('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
|
||||
logger.info(req.body);
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
app.getAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
|
||||
app.get('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
|
||||
logger.info(req.body);
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
app.getAsync('/api/status', async (req, res, next) => {
|
||||
app.get('/api/status', async (req, res, next) => {
|
||||
|
||||
const sourceData = scrobbleSources.sources.map((x) => {
|
||||
const {
|
||||
@@ -257,7 +257,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
return res.json({sources: sourceData, clients: clientData});
|
||||
});
|
||||
|
||||
app.getAsync('/api/recent', sourceMiddleFunc(false), async (req, res, next) => {
|
||||
app.get('/api/recent', sourceMiddleFunc(false), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
@@ -285,7 +285,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
app.getAsync('/api/source/art', sourceMiddleFunc(false), async (req, res, next) => {
|
||||
app.get('/api/source/art', sourceMiddleFunc(false), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource,
|
||||
@@ -313,7 +313,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
|
||||
app.get('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleClient: client,
|
||||
@@ -324,7 +324,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
app.putAsync('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
|
||||
app.put('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleClient: client,
|
||||
@@ -337,7 +337,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
await ((client as AbstractScrobbleClient).processDeadLetterQueue(1000));
|
||||
});
|
||||
|
||||
app.putAsync('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
|
||||
app.put('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleClient: client,
|
||||
@@ -366,7 +366,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
return res.json(dead);
|
||||
});
|
||||
|
||||
app.deleteAsync('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
|
||||
app.delete('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleClient: client,
|
||||
@@ -379,7 +379,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
return res.json([]);
|
||||
});
|
||||
|
||||
app.deleteAsync('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
|
||||
app.delete('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleClient: client,
|
||||
@@ -403,7 +403,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
return res.status(200).send();
|
||||
});
|
||||
|
||||
app.getAsync('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => {
|
||||
app.get('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error scrobbleClient not part of req
|
||||
scrobbleClient: client,
|
||||
@@ -418,7 +418,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
});
|
||||
|
||||
app.use('/api/source/init', sourceRequiredMiddle);
|
||||
app.postAsync('/api/source/init', async (req, res) => {
|
||||
app.post('/api/source/init', async (req, res) => {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
const source = req.scrobbleSource as AbstractSource;
|
||||
|
||||
@@ -446,7 +446,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
});
|
||||
|
||||
app.use('/api/source/listen', sourceRequiredMiddle);
|
||||
app.postAsync('/api/source/listen', async (req, res) => {
|
||||
app.post('/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;
|
||||
|
||||
@@ -474,7 +474,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
});
|
||||
|
||||
app.use('/api/client/init', clientRequiredMiddle);
|
||||
app.postAsync('/api/client/init', async (req, res) => {
|
||||
app.post('/api/client/init', async (req, res) => {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
const client = req.scrobbleClient as AbstractScrobbleClient;
|
||||
|
||||
@@ -498,8 +498,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
app.getAsync('/health', async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`));
|
||||
app.getAsync('/api/health', async (req, res) => {
|
||||
app.get('/health', async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`));
|
||||
app.get('/api/health', async (req, res) => {
|
||||
const {
|
||||
type,
|
||||
name
|
||||
@@ -549,7 +549,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
prom.collectDefaultMetrics();
|
||||
}
|
||||
|
||||
app.getAsync('/api/metrics', async (req, res) => {
|
||||
app.get('/api/metrics', async (req, res) => {
|
||||
|
||||
const metricsString = await prom.register.metrics();
|
||||
return res
|
||||
@@ -559,11 +559,11 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
|
||||
|
||||
});
|
||||
|
||||
app.getAsync('/api/version', async (req, res) => {
|
||||
app.get('/api/version', async (req, res) => {
|
||||
return res.json({version: root.get('version')});
|
||||
});
|
||||
|
||||
app.useAsync('/api/*', async (req, res) => {
|
||||
app.use('/api/*path', async (req, res) => {
|
||||
const remote = req.connection.remoteAddress;
|
||||
const proxyRemote = req.headers["x-forwarded-for"];
|
||||
const ua = req.headers["user-agent"];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Express } from 'express';
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import passport from "passport";
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic.js";
|
||||
@@ -18,9 +18,9 @@ import LibrefmSource from "../sources/LibrefmSource.js";
|
||||
import e from "express";
|
||||
import AbstractSource from "../sources/AbstractSource.js";
|
||||
|
||||
export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
app.use('/api/client/auth', clientMiddle);
|
||||
app.getAsync('/api/client/auth', async (req, res) => {
|
||||
app.get('/api/client/auth', async (req, res) => {
|
||||
const {
|
||||
scrobbleClient,
|
||||
} = req as any;
|
||||
@@ -40,7 +40,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
|
||||
});
|
||||
|
||||
app.use('/api/source/auth', sourceMiddle);
|
||||
app.getAsync('/api/source/auth', async (req, res, next) => {
|
||||
app.get('/api/source/auth', async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
@@ -74,7 +74,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync(/.*callback$/, async (req, res, next) => {
|
||||
app.get(/.*callback$/, async (req, res, next) => {
|
||||
if(req.url.indexOf('/api') !== 0) {
|
||||
return res.redirect(307, `/api${req.url}`);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync(/(\/api\/tealfm\/.*)/, async function (req, res) {
|
||||
app.get(/(\/api\/tealfm\/.*)/, async function (req, res) {
|
||||
|
||||
const clients = scrobbleClients.getByType('tealfm') as TealScrobbler[];
|
||||
if (clients.length === 0) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Express } from 'express';
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import passport from "passport";
|
||||
import DeezerSource from "../sources/DeezerSource.js";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { sleep } from "../utils.js";
|
||||
|
||||
export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
export const setupDeezerRoutes = (app: Express, logger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
// initialize deezer strategies
|
||||
// const deezerSources = scrobbleSources.getByType('deezer') as DeezerSource[];
|
||||
@@ -15,7 +15,7 @@ export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbl
|
||||
|
||||
// something about the deezer passport strategy makes express continue with the response even though it should wait for accesstoken callback and userprofile fetching
|
||||
// so to get around this add an additional middleware that loops/sleeps until we should have fetched everything ¯\_(ツ)_/¯
|
||||
app.getAsync(/.*deezer\/callback*$/, (req, res, next) => {
|
||||
app.get(/.*deezer\/callback*$/, (req, res, next) => {
|
||||
if(req.url.indexOf('/api') !== 0) {
|
||||
return res.redirect(307, `/api${req.url}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Express } from 'express';
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import bodyParser from "body-parser";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
@@ -8,14 +8,14 @@ import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNoti
|
||||
import { EndpointLastfmSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointLastfmSource.js";
|
||||
import { LastFMScrobbleRequestPayload } from "../common/vendor/LastfmApiClient.js";
|
||||
|
||||
export const setupLastfmEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
export const setupLastfmEndpointRoutes = (app: Express, 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\/?)$/,
|
||||
app.use(/(\/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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Express } from 'express';
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import bodyParser from "body-parser";
|
||||
import { EndpointListenbrainzSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointListenbrainzSource.js";
|
||||
@@ -7,10 +7,14 @@ import { LZEndpointNotifier } from "../sources/ingressNotifiers/LZEndpointNotifi
|
||||
import ScrobbleSources from "../sources/ScrobbleSources.js";
|
||||
import { nonEmptyBody } from "./middleware.js";
|
||||
import { isDebugMode } from "../utils.js";
|
||||
import { PlayingNowPayload } from '../common/vendor/listenbrainz/interfaces.js';
|
||||
import ScrobbleClients from '../scrobblers/ScrobbleClients.js';
|
||||
import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js';
|
||||
import { stringToDeterministicNumber } from '../utils/StringUtils.js';
|
||||
|
||||
const TEXT_WILDCARD_REGEX = new RegExp(/text\/.+/);
|
||||
|
||||
export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
export const setupLZEndpointRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
|
||||
const logger = childLogger(parentLogger, ['Ingress', 'Listenbrainz']);
|
||||
|
||||
@@ -30,7 +34,7 @@ export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logge
|
||||
const nonEmptyCheck = nonEmptyBody(logger, 'LZ Endpoint');
|
||||
|
||||
const webhookIngress = new LZEndpointNotifier(logger);
|
||||
app.useAsync(/(\/api\/listenbrainz.*)|(\/1\/submit-listens\/?$)/,
|
||||
app.use(/(\/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)
|
||||
@@ -67,19 +71,66 @@ export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logge
|
||||
await source.handle(playerState);
|
||||
}
|
||||
});
|
||||
app.getAsync('/1/validate-token', async function (req, res) {
|
||||
|
||||
app.get('/1/user/:username/playing-now', async function (req, res) {
|
||||
// TODO need to implement user names for endpoint configs
|
||||
// so we can identify playing now calls by user
|
||||
// and then determine actual playing now by clients that are able to be scrobbled to from this source
|
||||
//
|
||||
// but for now just stub out empty response so panoscrobbler doesn't complain
|
||||
|
||||
const user = req.params.username;
|
||||
let listens: PlayingNowPayload[];
|
||||
|
||||
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 matchedSource = sources.find(x => x.config.data?.username === user || x.name === user);
|
||||
|
||||
const playObjs = scrobbleClients.getPlayingNow(matchedSource.name, matchedSource.clients);
|
||||
listens = playObjs.map(x => ({playing_now: true, track_metadata: playToListenPayload(x).track_metadata}));
|
||||
|
||||
return res.status(200).json({
|
||||
payload: {
|
||||
listens,
|
||||
playing_now: true,
|
||||
user_id: stringToDeterministicNumber(user),
|
||||
count: listens.length
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/1/validate-token', async function (req, res) {
|
||||
//https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-validate-token
|
||||
|
||||
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 => Token: ${token}`);
|
||||
}
|
||||
|
||||
let username = "Multi-Scrobbler";
|
||||
if(validSources.length > 0) {
|
||||
username = validSources[0].config.data.username ?? validSources[0].name;
|
||||
}
|
||||
|
||||
logger.info('Validated token');
|
||||
return res.status(200).json({
|
||||
code: 200,
|
||||
message: "Token valid.",
|
||||
valid: true,
|
||||
user_name: "Multi-Scrobbler"
|
||||
user_name: username
|
||||
})
|
||||
});
|
||||
app.useAsync(/\/1\/.*/, async function (req, res) {
|
||||
app.use(/\/1\/.*/, async function (req, res) {
|
||||
logger.warn(`Received what looks like a Listenbrainz Endpoint request but it was to an invalid URL route: ${req.originalUrl}\nMake sure base URL path to MS endpoint is correct.`);
|
||||
res.status(404);
|
||||
res.sendStatus(404);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { addAsync, Router } from '@awaitjs/express';
|
||||
import express, { Router } from 'express';
|
||||
import { childLogger, LogDataPretty, Logger } from "@foxxmd/logging";
|
||||
import bodyParser from 'body-parser';
|
||||
import { stripIndents } from "common-tags";
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import { PassThrough } from "node:stream";
|
||||
import passport from 'passport';
|
||||
@@ -16,7 +15,7 @@ import { setupApi } from "./api.js";
|
||||
import ScrobbleSources from '../sources/ScrobbleSources.js';
|
||||
import ScrobbleClients from '../scrobblers/ScrobbleClients.js';
|
||||
|
||||
const app = addAsync(express());
|
||||
const app = express();
|
||||
const router = Router();
|
||||
|
||||
export const initServer = async (parentLogger: Logger, appLoggerStream: PassThrough, initialOutput: LogDataPretty[] = [], sources: ScrobbleSources, clients: ScrobbleClients) => {
|
||||
@@ -83,16 +82,24 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
|
||||
process.env.USE_HASH_ROUTER = root.get('isSubPath').toString();
|
||||
}
|
||||
|
||||
const conf = await ViteExpress.getViteConfig();
|
||||
|
||||
|
||||
ViteExpress.config({
|
||||
const viteExpressOptions: Parameters<typeof ViteExpress.config>[0] = {
|
||||
mode: isProd ? 'production' : 'development',
|
||||
inlineViteConfig: {
|
||||
...conf,
|
||||
base: localDefined && local.pathname !== '/' ? local.toString() : '/'
|
||||
}
|
||||
});
|
||||
ViteExpress.getViteConfig
|
||||
};
|
||||
|
||||
if(!isProd) {
|
||||
const conf = await ViteExpress.getViteConfig();
|
||||
viteExpressOptions.inlineViteConfig = {
|
||||
...viteExpressOptions.inlineViteConfig,
|
||||
...conf
|
||||
};
|
||||
}
|
||||
|
||||
ViteExpress.config(viteExpressOptions);
|
||||
try {
|
||||
ViteExpress.listen(app, port, () => {
|
||||
const start = stripIndents`\n
|
||||
@@ -115,3 +122,14 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
|
||||
throw new Error('Server crashed with uncaught exception', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
type ViteConfig = {
|
||||
root: string;
|
||||
base: string;
|
||||
build: {
|
||||
outDir: string;
|
||||
};
|
||||
server?: {
|
||||
hmr?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -63,4 +63,5 @@ export const nonEmptyBody = (logger: Logger, origin: string = 'Origin'): Express
|
||||
res.status(400).send('Invalid Content-Type. Must be either application/json or a text wildcard (like text/plain)');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExpressWithAsync } from "@awaitjs/express";
|
||||
import { Express } from 'express';
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import bodyParser from "body-parser";
|
||||
import cors from 'cors';
|
||||
@@ -12,7 +12,7 @@ const corsOpts: cors.CorsOptions = {
|
||||
methods: ['POST']
|
||||
}
|
||||
|
||||
export const setupWebscrobblerRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
export const setupWebscrobblerRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
const logger = childLogger(parentLogger, ['Ingress', 'WebScrobbler']);
|
||||
|
||||
@@ -25,13 +25,13 @@ export const setupWebscrobblerRoutes = (app: ExpressWithAsync, parentLogger: Log
|
||||
// }
|
||||
});
|
||||
const webhookIngress = new WebhookNotifier(logger);
|
||||
app.options('/api/webscrobbler*', async (req, res, next) => {
|
||||
app.options('/api/webscrobbler*path', async (req, res, next) => {
|
||||
webhookIngress.trackIngress(req, true);
|
||||
next();
|
||||
},
|
||||
cors(corsOpts));
|
||||
|
||||
app.postAsync('/api/webscrobbler*',
|
||||
app.post(/api\/webscrobbler\/?.*/,
|
||||
async (req, res, next) => {
|
||||
webhookIngress.trackIngress(req, true);
|
||||
next();
|
||||
|
||||
@@ -92,7 +92,7 @@ export const playStateFromRequest = (obj: LastFMScrobbleRequestPayload): PlayerS
|
||||
platformId: [play.meta.deviceId, NO_USER],
|
||||
play,
|
||||
status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown,
|
||||
timestamp: dayjs()
|
||||
stateUpdatedAt: dayjs()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
|
||||
|
||||
const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?)$/i);
|
||||
const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?\/1\/validate-token\/|)$/i);
|
||||
const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i);
|
||||
|
||||
export const authHeaderRegex = new RegExp(/Token (.+)$/i);
|
||||
@@ -116,7 +116,7 @@ export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => {
|
||||
platformId: [play.meta.deviceId, NO_USER],
|
||||
play,
|
||||
status: listenTypeAsPlayerStatus(listen_type),
|
||||
timestamp: dayjs()
|
||||
stateUpdatedAt: dayjs()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,6 @@ import {
|
||||
from "@jellyfin/sdk/lib/index.js";
|
||||
import dayjs from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import { nanoid } from "nanoid";
|
||||
import pEvent from "p-event";
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js";
|
||||
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import {
|
||||
@@ -511,6 +508,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
for(const sessionData of nonMSSessions) {
|
||||
const validPlay = this.isActivityValid(sessionData[0], sessionData[1]);
|
||||
if(validPlay === true) {
|
||||
if(isDebugMode()) {
|
||||
let stateIdentifyingInfo: string = genGroupIdStr(getPlatformIdFromData(sessionData[0]));
|
||||
this.logger.trace(`${stateIdentifyingInfo} => Activity Date ${sessionData[1].LastActivityDate} | Playback Checkin: ${sessionData[1].LastPlaybackCheckIn} `)
|
||||
}
|
||||
validSessions.push(sessionData[0]);
|
||||
} else if(this.logFilterFailure !== false) {
|
||||
let stateIdentifyingInfo: string = genGroupIdStr(getPlatformIdFromData(sessionData[0]));
|
||||
@@ -538,9 +539,11 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
DeviceName,
|
||||
Client,
|
||||
LastActivityDate,
|
||||
LastPlaybackCheckIn,
|
||||
PlayState: {
|
||||
PositionTicks,
|
||||
IsPaused
|
||||
IsPaused,
|
||||
CanSeek
|
||||
}
|
||||
} = obj;
|
||||
|
||||
@@ -560,7 +563,9 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
...sessionPlay.meta,
|
||||
user: UserName ?? UserId,
|
||||
deviceId: msDeviceId,
|
||||
trackProgressPosition: playerPosition
|
||||
trackProgressPosition: playerPosition,
|
||||
//lastActivityDate: LastActivityDate,
|
||||
//lastPlaybackCheckin: LastPlaybackCheckIn
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,11 +580,15 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
reportedStatus = IsPaused ? REPORTED_PLAYER_STATUSES.paused : REPORTED_PLAYER_STATUSES.playing;
|
||||
}
|
||||
|
||||
const sessionUpdatedAt = LastActivityDate !== undefined ? dayjs(LastActivityDate) : undefined;
|
||||
|
||||
return {
|
||||
platformId: [msDeviceId, UserName ?? UserId],
|
||||
play,
|
||||
status: reportedStatus,
|
||||
position: playerPosition,
|
||||
stateUpdatedAt: sessionUpdatedAt,
|
||||
playUpdatedAt: sessionUpdatedAt
|
||||
//timestamp: LastActivityDate !== undefined ? dayjs(LastActivityDate) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import { EventEmitter } from "events";
|
||||
import mpdapiNS, { MPDApi } from 'mpd-api';
|
||||
import mpd2 from 'mpd2';
|
||||
import path from 'path';
|
||||
import {MPC, type Status, type Song, type PlaylistItem} from 'mpc-js';
|
||||
import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
@@ -12,17 +12,14 @@ import {
|
||||
SINGLE_USER_PLATFORM_ID,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import {
|
||||
CurrentSongResponse,
|
||||
MPDSourceConfig,
|
||||
PlayerState,
|
||||
StatusResponse,
|
||||
} from "../common/infrastructure/config/source/mpd.js";
|
||||
import { isPortReachable } from "../utils/NetworkUtils.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
|
||||
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js";
|
||||
|
||||
const mpdClient = mpdapiNS.default;
|
||||
import { isDebugMode, sleep } from "../utils.js";
|
||||
|
||||
const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
|
||||
'play': REPORTED_PLAYER_STATUSES.playing,
|
||||
@@ -35,11 +32,12 @@ export class MPDSource extends MemoryPositionalSource {
|
||||
|
||||
host?: string
|
||||
port?: number
|
||||
// {host?: string, port?: number, path?: string, password?: string};
|
||||
clientConfig: mpd2.MPD.Config;
|
||||
client!: MPDApi.ClientAPI;
|
||||
mpc: MPC;
|
||||
deviceId: string
|
||||
|
||||
protected currentPlayPath: string;
|
||||
protected currentPlaySong?: Song;
|
||||
|
||||
constructor(name: any, config: MPDSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data = {}
|
||||
@@ -68,7 +66,6 @@ export class MPDSource extends MemoryPositionalSource {
|
||||
data: {
|
||||
url,
|
||||
path,
|
||||
password,
|
||||
} = {}
|
||||
} = this.config;
|
||||
|
||||
@@ -77,17 +74,8 @@ export class MPDSource extends MemoryPositionalSource {
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${host}:${port}'`);
|
||||
this.host = host;
|
||||
this.port = Number.parseInt(port);
|
||||
this.clientConfig = {
|
||||
host,
|
||||
port: this.port,
|
||||
password
|
||||
}
|
||||
} else {
|
||||
this.logger.verbose(`Using socket path: ${path}`);
|
||||
this.clientConfig = {
|
||||
path,
|
||||
password
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -108,13 +96,32 @@ export class MPDSource extends MemoryPositionalSource {
|
||||
doAuthentication = async () => {
|
||||
|
||||
try {
|
||||
this.client = await mpdClient.connect({...this.clientConfig, timeout: 1000});
|
||||
this.client.on('system-player', () => {
|
||||
if(this.getIsSleeping()) {
|
||||
// wake up now!
|
||||
this.mpc = new MPC();
|
||||
if(this.host !== undefined) {
|
||||
const prom = this.mpc.connectTCP(this.host, this.port)
|
||||
await Promise.race([
|
||||
prom,
|
||||
sleep(1000)
|
||||
]);
|
||||
if(!this.mpc.isReady) {
|
||||
// handled any rejected socket promise error, if it occurs later
|
||||
prom.catch(err => this.logger.warn(err));
|
||||
this.mpc.disconnect();
|
||||
throw new Error('Timed out waiting for TCP response from MPD');
|
||||
}
|
||||
} else {
|
||||
await this.mpc.connectUnixSocket(this.config.data.path);
|
||||
}
|
||||
|
||||
if(this.config.data.password !== undefined) {
|
||||
await this.mpc.connection.password(this.config.data.password);
|
||||
}
|
||||
this.mpc.on('changed', (p) => {
|
||||
if(p.includes('player') && this.getIsSleeping()) {
|
||||
// wake up now!
|
||||
this.logger.debug(`Waking up from sleeping ${Math.abs(this.getWakeAt().diff(dayjs(), 'ms'))}ms early due to player state change`)
|
||||
this.setWakeAt(dayjs());
|
||||
}
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -138,54 +145,113 @@ export class MPDSource extends MemoryPositionalSource {
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj(obj: CurrentSongResponse, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
formatPlayObj(obj: Song | PlaylistItem, options: FormatPlayObjectOptions & {state?: Status} = {}): PlayObject {
|
||||
|
||||
let trackName: string,
|
||||
album: string,
|
||||
artists: string[] = [],
|
||||
albumArtists: string[] = [],
|
||||
duration: number,
|
||||
position: number,
|
||||
brainz: BrainzMeta = {};
|
||||
|
||||
const {
|
||||
file,
|
||||
time,
|
||||
artist,
|
||||
performer,
|
||||
album,
|
||||
albumartist,
|
||||
title,
|
||||
name,
|
||||
musicbrainz_albumartistid,
|
||||
musicbrainz_albumid,
|
||||
musicbrainz_artistid,
|
||||
musicbrainz_releasetrackid,
|
||||
musicbrainz_trackid,
|
||||
} = obj;
|
||||
state: {
|
||||
elapsed: sElapsed,
|
||||
duration: sDuration
|
||||
} = {}
|
||||
} = options;
|
||||
|
||||
let artists = [];
|
||||
let albumArtists = [];
|
||||
if(artist !== undefined) {
|
||||
artists.push(artist);
|
||||
}
|
||||
if(albumartist !== undefined && albumartist !== artist) {
|
||||
albumArtists.push(albumartist);
|
||||
}
|
||||
if(artists.length === 0 && performer !== undefined) {
|
||||
artists.push(performer);
|
||||
}
|
||||
if(artists.length === 0 && albumArtists.length !== 0) {
|
||||
// switch these, tags are probably improper
|
||||
artists = albumArtists;
|
||||
albumArtists = [];
|
||||
position = sElapsed;
|
||||
|
||||
if('entryType' in obj && obj.entryType === 'song') {
|
||||
const {
|
||||
path: file,
|
||||
duration: songDuration,
|
||||
artist,
|
||||
performer,
|
||||
album: sAlbum,
|
||||
albumArtist,
|
||||
title,
|
||||
name,
|
||||
musicBrainzAlbumArtistId: musicbrainz_albumartistid,
|
||||
musicBrainzAlbumId: musicbrainz_albumid,
|
||||
musicBrainzArtistId: musicbrainz_artistid,
|
||||
musicBrainzReleaseTrackId: musicbrainz_releasetrackid,
|
||||
musicBrainzTrackId: musicbrainz_trackid,
|
||||
} = obj;
|
||||
|
||||
trackName = title;
|
||||
if(trackName === undefined && name !== undefined) {
|
||||
trackName = name;
|
||||
} else if(trackName === undefined && file !== undefined) {
|
||||
const pathSplit = file.split(path.sep);
|
||||
if(pathSplit.length > 1) {
|
||||
trackName = pathSplit[pathSplit.length - 1];
|
||||
} else {
|
||||
trackName = file;
|
||||
}
|
||||
}
|
||||
|
||||
if(artist !== undefined) {
|
||||
artists.push(artist);
|
||||
}
|
||||
if(albumArtist !== undefined && albumArtist !== artist) {
|
||||
albumArtists.push(albumArtist);
|
||||
}
|
||||
if(artists.length === 0 && performer !== undefined) {
|
||||
artists.push(performer);
|
||||
}
|
||||
if(artists.length === 0 && albumArtists.length !== 0) {
|
||||
// switch these, tags are probably improper
|
||||
artists = albumArtists;
|
||||
albumArtists = [];
|
||||
}
|
||||
|
||||
album = sAlbum;
|
||||
|
||||
duration = songDuration ?? sDuration;
|
||||
|
||||
brainz = {
|
||||
albumArtist: musicbrainz_albumartistid !== undefined ? [musicbrainz_albumartistid] : undefined,
|
||||
album: musicbrainz_albumid,
|
||||
recording: musicbrainz_trackid,
|
||||
artist: musicbrainz_artistid !== undefined ? [musicbrainz_artistid] : undefined
|
||||
};
|
||||
|
||||
} else {
|
||||
const {
|
||||
path: file,
|
||||
duration: songDuration,
|
||||
artist,
|
||||
album: pAlbum,
|
||||
albumArtist,
|
||||
title,
|
||||
name
|
||||
} = obj;
|
||||
|
||||
trackName = title ?? name;
|
||||
if(trackName === undefined) {
|
||||
const pathSplit = file.split(path.sep);
|
||||
if(pathSplit.length > 1) {
|
||||
trackName = pathSplit[pathSplit.length - 1];
|
||||
} else {
|
||||
trackName = file;
|
||||
}
|
||||
}
|
||||
|
||||
artists = artist !== undefined ? [artist] : undefined;
|
||||
album = pAlbum;
|
||||
albumArtists = albumArtist !== undefined && albumArtist !== artist ? [albumArtist] : undefined;
|
||||
duration = songDuration ?? sDuration;
|
||||
}
|
||||
|
||||
let trackName = title;
|
||||
if(trackName === undefined && name !== undefined) {
|
||||
trackName = name;
|
||||
} else if(trackName === undefined && file !== undefined) {
|
||||
trackName = file;
|
||||
if(duration !== undefined) {
|
||||
duration = Math.floor(duration);
|
||||
}
|
||||
|
||||
const brainz: BrainzMeta = {
|
||||
albumArtist: [musicbrainz_albumartistid],
|
||||
album: musicbrainz_albumid,
|
||||
recording: musicbrainz_trackid,
|
||||
};
|
||||
if(musicbrainz_artistid !== undefined) {
|
||||
brainz.artist = [musicbrainz_artistid];
|
||||
if(position !== undefined) {
|
||||
// so that we can end up with 100% played
|
||||
position = Math.ceil(position);
|
||||
}
|
||||
|
||||
const play: PlayObjectLifecycleless = {
|
||||
@@ -194,40 +260,66 @@ export class MPDSource extends MemoryPositionalSource {
|
||||
albumArtists,
|
||||
album,
|
||||
track: trackName,
|
||||
duration: time
|
||||
duration
|
||||
},
|
||||
meta: {
|
||||
brainz,
|
||||
trackProgressPosition: options.trackProgressPosition,
|
||||
trackProgressPosition: position,
|
||||
mediaPlayerName: 'mpd'
|
||||
}
|
||||
}
|
||||
return baseFormatPlayObj({...obj, trackProgressPosition: options.trackProgressPosition}, play);
|
||||
return baseFormatPlayObj({...obj, trackProgressPosition: position}, play);
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
|
||||
let state: StatusResponse;
|
||||
let currentSong: CurrentSongResponse;
|
||||
let mpcCurrentItem: PlaylistItem,
|
||||
//mpcSong: Song,
|
||||
mpcStatus: Status;
|
||||
try {
|
||||
state = await this.client.api.status.get<StatusResponse>();
|
||||
currentSong = await this.client.api.status.currentsong<CurrentSongResponse>();
|
||||
mpcCurrentItem = await this.mpc.status.currentSong();
|
||||
mpcStatus = await this.mpc.status.status();
|
||||
} catch (e) {
|
||||
this.connectionOK = false;
|
||||
this.authed = false;
|
||||
throw e;
|
||||
}
|
||||
|
||||
let play: PlayObject | undefined;
|
||||
if(currentSong !== undefined) {
|
||||
play = this.formatPlayObj(currentSong, {trackProgressPosition: state.elapsed});
|
||||
let play: PlayObject | undefined,
|
||||
newPath = false;
|
||||
if(mpcCurrentItem !== undefined && mpcCurrentItem.path !== undefined) {
|
||||
if(this.currentPlayPath !== mpcCurrentItem.path) {
|
||||
newPath = true;
|
||||
this.currentPlaySong = undefined;
|
||||
this.currentPlayPath = mpcCurrentItem.path;
|
||||
try {
|
||||
const resp = await this.mpc.database.listInfo(mpcCurrentItem.path);
|
||||
if(resp.length > 0 && resp[0].isSong()) {
|
||||
//mpcSong = resp[0];
|
||||
this.currentPlaySong = resp[0];
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Could not retrieve Song db info for uri ${mpcCurrentItem.path}`);
|
||||
}
|
||||
}
|
||||
play = this.formatPlayObj(this.currentPlaySong ?? mpcCurrentItem, { state: mpcStatus });
|
||||
|
||||
if(newPath) {
|
||||
this.logger.trace('Current playing is a new path. Logging payload/Play on first seen for this path');
|
||||
this.logger.trace(`MPD Payload => ${JSON.stringify({currentItem: mpcCurrentItem, status: mpcStatus, song: this.currentPlaySong})}`);
|
||||
this.logger.trace(`MS Play => ${JSON.stringify(play)}`);
|
||||
}
|
||||
|
||||
if(isDebugMode() && !newPath) {
|
||||
this.logger.trace(`Raw mpc.js payload => ${JSON.stringify({mpcStatus, mpcSong: mpcCurrentItem})}`);
|
||||
}
|
||||
}
|
||||
|
||||
const playerState: PlayerStateData = {
|
||||
platformId: SINGLE_USER_PLATFORM_ID,
|
||||
status: CLIENT_PLAYER_STATE[state.state],
|
||||
status: CLIENT_PLAYER_STATE[mpcStatus.state],
|
||||
play,
|
||||
position: state.elapsed
|
||||
position: play?.meta?.trackProgressPosition
|
||||
}
|
||||
|
||||
return await this.processRecentPlays([playerState]);
|
||||
|
||||
@@ -48,6 +48,8 @@ export default class MemorySource extends AbstractSource {
|
||||
playerState: Map<string, string> = new Map();
|
||||
playerCleanupDiscoveryAttempt: Map<string, boolean> = new Map();
|
||||
|
||||
deceasedPlayers: Map<string, Dayjs> = new Map();
|
||||
|
||||
scheduler: ToadScheduler = new ToadScheduler();
|
||||
|
||||
protected isPositional: boolean = false;
|
||||
@@ -196,6 +198,10 @@ export default class MemorySource extends AbstractSource {
|
||||
return this.players.has(id);
|
||||
}
|
||||
|
||||
isZombiePlayer = (id: string, lastUpdated: Dayjs): boolean => {
|
||||
return this.deceasedPlayers.has(id) && this.deceasedPlayers.get(id).isSame(lastUpdated);
|
||||
}
|
||||
|
||||
genPlayerId = (data: PlayObject | PlayerStateDataMaybePlay): string => {
|
||||
return genGroupIdStr(getPlatformIdFromData(data));
|
||||
}
|
||||
@@ -208,6 +214,7 @@ export default class MemorySource extends AbstractSource {
|
||||
this.players.get(id)?.logger.debug(reason);
|
||||
}
|
||||
using player = this.players.get(id);
|
||||
this.deceasedPlayers.set(id, player.stateLastUpdatedAt);
|
||||
player[Symbol.dispose]();
|
||||
this.players.delete(id);
|
||||
this.playerState.delete(id);
|
||||
@@ -236,6 +243,15 @@ export default class MemorySource extends AbstractSource {
|
||||
const id = getPlatformIdFromData(data);
|
||||
const idStr = this.genPlayerId(data);
|
||||
if (!this.players.has(idStr)) {
|
||||
if(asPlayerStateDataMaybePlay(data) && data.stateUpdatedAt !== undefined) {
|
||||
if(this.isZombiePlayer(idStr, data.stateUpdatedAt)) {
|
||||
this.logger.trace(`Not creating player for ${idStr} because last state update timestamp has not changed since it was deleted.`);
|
||||
continue;
|
||||
} else {
|
||||
// cleaning up in case it already existed but has new timestamp
|
||||
this.deceasedPlayers.delete(idStr);
|
||||
}
|
||||
}
|
||||
this.setNewPlayer(idStr, this.logger, id);
|
||||
|
||||
if(!this.multiPlatform && this.players.size > 1) {
|
||||
@@ -257,8 +273,19 @@ export default class MemorySource extends AbstractSource {
|
||||
return player.platformEquals(id);
|
||||
});
|
||||
|
||||
// we've received some form of communication from the source for this player
|
||||
let hasFreshState = false;
|
||||
if (relevantDatas.length > 0) {
|
||||
hasFreshState = true;
|
||||
this.lastActivityAt = dayjs();
|
||||
incomingData = this.pickPlatformSession(relevantDatas, player);
|
||||
if(asPlayerStateDataMaybePlay(incomingData) && incomingData.stateUpdatedAt !== undefined && player.stateLastUpdatedAt.isSame(incomingData.stateUpdatedAt)) {
|
||||
hasFreshState = false;
|
||||
player.logger.trace('Skipping update because it has the same timestamp as the previous update');
|
||||
}
|
||||
}
|
||||
|
||||
// we've received some form of communication from the source for this player
|
||||
if (hasFreshState) {
|
||||
this.lastActivityAt = dayjs();
|
||||
|
||||
// reset any player cleanup state since we got fresh data
|
||||
|
||||
@@ -83,7 +83,7 @@ export abstract class AbstractPlayerState {
|
||||
currentListenRange?: ListenRange
|
||||
listenRanges: ListenRange[] = [];
|
||||
createdAt: Dayjs = dayjs();
|
||||
stateLastUpdatedAt: Dayjs = dayjs();
|
||||
stateLastUpdatedAt: Dayjs = dayjs(0);
|
||||
|
||||
lastPlay?: PlayObject
|
||||
lastPlayUpdatedAt?: Dayjs
|
||||
@@ -171,7 +171,7 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
|
||||
update(state: PlayerStateDataMaybePlay, reportedTS?: Dayjs) {
|
||||
this.stateLastUpdatedAt = dayjs();
|
||||
this.stateLastUpdatedAt = state.stateUpdatedAt ?? dayjs();
|
||||
|
||||
const {play, status} = state;
|
||||
|
||||
@@ -194,8 +194,8 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
|
||||
protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] {
|
||||
const {play, status, sessionId} = state;
|
||||
this.playLastUpdatedAt = reportedTS ?? dayjs();
|
||||
const {play, status, sessionId, playUpdatedAt} = state;
|
||||
this.playLastUpdatedAt = reportedTS ?? playUpdatedAt ?? dayjs();
|
||||
if (status !== undefined) {
|
||||
this.reportedStatus = status;
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export abstract class AbstractPlayerState {
|
||||
const played = this.getPlayedObject(true);
|
||||
this.isRepeatPlay = false;
|
||||
this.lastPlay = played;
|
||||
this.lastPlayUpdatedAt = dayjs();
|
||||
this.lastPlayUpdatedAt = playUpdatedAt ?? dayjs();
|
||||
this.setCurrentPlay(state, {reportedTS});
|
||||
if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
|
||||
@@ -298,7 +298,7 @@ export abstract class AbstractPlayerState {
|
||||
...this.currentPlay.data,
|
||||
playDate: this.playFirstSeenAt,
|
||||
listenedFor: this.getListenDuration(),
|
||||
listenRanges: ranges,
|
||||
listenRanges: ranges.map(x => ({start: x.start, end: x.end})),
|
||||
playDateCompleted: completed ? dayjs() : undefined,
|
||||
repeat: this.isRepeatPlay
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
import { childLogger, Logger } from '@foxxmd/logging';
|
||||
import EventEmitter from "events";
|
||||
import { ConfigMeta, CONFIGURE_AS, ConfigureAs, ConfigureAsSource, InternalConfig, InternalConfigOptional } from "../common/infrastructure/Atomic.js";
|
||||
import { ConfigMeta, ConfigureAs, ConfigureAsSource, InternalConfig, InternalConfigOptional } from "../common/infrastructure/Atomic.js";
|
||||
import { isSourceType } from '../common/infrastructure/config/source/sources.js';
|
||||
import { sourceTypes } from '../common/infrastructure/config/source/sources.js';
|
||||
import { SourceType } from '../common/infrastructure/config/source/sources.js';
|
||||
@@ -270,7 +270,7 @@ export default class ScrobbleSources {
|
||||
}
|
||||
|
||||
for (const sourceType of sourceTypes) {
|
||||
let defaultConfigureAs: ConfigureAsSource = CONFIGURE_AS.source;
|
||||
let defaultConfigureAs: ConfigureAsSource = 'source';
|
||||
// env builder for single user mode
|
||||
switch (sourceType) {
|
||||
case 'spotify':
|
||||
@@ -540,7 +540,8 @@ export default class ScrobbleSources {
|
||||
const lzShouldUse = parseBool(process.env.LZENDPOINT_ENABLE);
|
||||
const lze: ListenbrainzEndpointData = {
|
||||
slug: process.env.LZE_SLUG,
|
||||
token: process.env.LZE_TOKEN
|
||||
token: process.env.LZE_TOKEN,
|
||||
username: process.env.LZE_USERNAME
|
||||
}
|
||||
if (!Object.values(lze).every(x => x === undefined) || lzShouldUse) {
|
||||
configs.push({
|
||||
|
||||
@@ -520,7 +520,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag
|
||||
platformId: [combinePartsToString([shortDeviceId(device.id), device.name]), NO_USER],
|
||||
status,
|
||||
play: item !== null && item !== undefined ? SpotifySource.formatPlayObj(res.body, {newFromSource: true}) : undefined,
|
||||
timestamp: dayjs(timestamp),
|
||||
stateUpdatedAt: dayjs(timestamp),
|
||||
position: progress_ms !== null && progress_ms !== undefined ? progress_ms / 1000 : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export class WebScrobblerSource extends MemorySource {
|
||||
platformId: [play.meta.deviceId, NO_USER],
|
||||
play,
|
||||
status: WebScrobblerSource.webhookEventAsPlayerStatus(eventName),
|
||||
timestamp: dayjs.unix(time)
|
||||
stateUpdatedAt: dayjs.unix(time)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,15 +29,21 @@ interface BridgeTrackData {
|
||||
explicit?: boolean
|
||||
context_type?: string
|
||||
queue_id?: string
|
||||
player_id?: string
|
||||
device_id?: string
|
||||
device_name?: string
|
||||
platform?: string
|
||||
source?: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
interface BridgeNowPlayingResponse {
|
||||
interface BridgePlayersResponse {
|
||||
ok: boolean
|
||||
source?: string
|
||||
fresh?: boolean
|
||||
data?: BridgeTrackData | null
|
||||
players_count?: number
|
||||
primary?: BridgeTrackData | null
|
||||
data?: BridgeTrackData[] | BridgeTrackData | null
|
||||
}
|
||||
|
||||
interface SyntheticPlaybackState {
|
||||
@@ -48,14 +54,51 @@ interface SyntheticPlaybackState {
|
||||
reachedTrackEndAtMs?: number
|
||||
}
|
||||
|
||||
|
||||
const normalizeBridgeData = (obj: BridgeTrackData): BridgeTrackData => {
|
||||
const normalized: BridgeTrackData = { ...obj };
|
||||
|
||||
if (normalized.progress_ms !== undefined && normalized.progress_ms !== null) {
|
||||
normalized.progress_ms = Math.max(0, Number(normalized.progress_ms) || 0);
|
||||
}
|
||||
|
||||
if (normalized.duration_ms !== undefined && normalized.duration_ms !== null) {
|
||||
const dur = Number(normalized.duration_ms);
|
||||
normalized.duration_ms = Number.isFinite(dur) && dur > 0 ? dur : undefined;
|
||||
}
|
||||
|
||||
if (normalized.duration_ms !== undefined && normalized.progress_ms !== undefined) {
|
||||
normalized.progress_ms = Math.min(normalized.progress_ms, normalized.duration_ms);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const getSafePositionSec = (obj: BridgeTrackData): number | undefined => {
|
||||
if (obj.progress_ms === undefined || obj.progress_ms === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const progressSec = Math.max(0, obj.progress_ms / 1000);
|
||||
const durationSec = obj.duration_ms !== undefined && obj.duration_ms !== null && obj.duration_ms > 0
|
||||
? obj.duration_ms / 1000
|
||||
: undefined;
|
||||
|
||||
if (obj.source === 'station-local') {
|
||||
return durationSec !== undefined ? Math.min(progressSec, durationSec) : 0;
|
||||
}
|
||||
|
||||
return durationSec !== undefined ? Math.min(progressSec, durationSec) : progressSec;
|
||||
}
|
||||
|
||||
export default class YandexMusicBridgeSource extends MemoryPositionalSource {
|
||||
|
||||
declare config: YandexMusicBridgeSourceConfig;
|
||||
urlData!: URLData;
|
||||
private syntheticPlayback?: SyntheticPlaybackState;
|
||||
private lastBridgeData?: BridgeTrackData;
|
||||
private lastPlay?: PlayObject;
|
||||
private lastBridgeSeenAtMs?: number;
|
||||
private syntheticPlaybackByPlayer = new Map<string, SyntheticPlaybackState>();
|
||||
private lastBridgeDataByPlayer = new Map<string, BridgeTrackData>();
|
||||
private lastPlayByPlayer = new Map<string, PlayObject>();
|
||||
private lastBridgeSeenAtMsByPlayer = new Map<string, number>();
|
||||
private keepAliveMinSec = 90;
|
||||
private keepAlivePaddingSec = 120;
|
||||
private keepAliveHardCapSec = 600;
|
||||
@@ -65,6 +108,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
|
||||
super('ymbridge', name, config, internal, emitter);
|
||||
this.requiresAuth = false;
|
||||
this.canPoll = true;
|
||||
this.multiPlatform = true;
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
@@ -91,36 +135,68 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
|
||||
this.logger.info(`Yandex Music bridge is reachable at ${this.urlData.url.host}`);
|
||||
return true;
|
||||
}
|
||||
throw new UpstreamError('Bridge health endpoint did not return JSON', {responseBody: resp.body, showStopper: true});
|
||||
throw new UpstreamError('Bridge health endpoint did not return JSON', { responseBody: resp.body, showStopper: true });
|
||||
} catch (e: any) {
|
||||
const hint = e?.response?.text ?? e?.message ?? undefined;
|
||||
throw new UpstreamError(`Could not connect to Yandex Music bridge${hint !== undefined ? ` (${hint})` : ''}`, { cause: e, responseBody: e?.response?.text, showStopper: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async callBridge(): Promise<BridgeNowPlayingResponse> {
|
||||
const bridgeUrl = joinedUrl(this.urlData.url, 'now-playing').toString();
|
||||
const req = request.get(bridgeUrl).timeout({ response: 5000, deadline: 10000 });
|
||||
private async callBridge(): Promise<BridgePlayersResponse> {
|
||||
const apiKey = this.config.data?.apiKey;
|
||||
if (apiKey !== undefined && apiKey.trim() !== '') {
|
||||
req.set('X-API-Key', apiKey);
|
||||
}
|
||||
try {
|
||||
const resp = await req;
|
||||
if (resp.body === undefined || typeof resp.body !== 'object') {
|
||||
throw new UpstreamError('Bridge returned no JSON payload', {responseBody: resp.body, showStopper: true});
|
||||
const makeReq = (path: string) => {
|
||||
const req = request.get(joinedUrl(this.urlData.url, path).toString()).timeout({ response: 5000, deadline: 10000 });
|
||||
if (apiKey !== undefined && apiKey.trim() !== '') {
|
||||
req.set('X-API-Key', apiKey);
|
||||
}
|
||||
return resp.body as BridgeNowPlayingResponse;
|
||||
} catch (e) {
|
||||
throw new UpstreamError('Bridge not return an expected response', {responseBody: e?.response?.text, cause: e, showStopper: true});
|
||||
return req;
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await makeReq('players');
|
||||
if (resp.body === undefined || typeof resp.body !== 'object') {
|
||||
throw new UpstreamError('Bridge returned no JSON payload', { responseBody: resp.body, showStopper: true });
|
||||
}
|
||||
return resp.body as BridgePlayersResponse;
|
||||
} catch (e: any) {
|
||||
if (e?.status !== 404) {
|
||||
throw new UpstreamError('Bridge did not return an expected response from /players', { responseBody: e?.response?.text, cause: e, showStopper: true });
|
||||
}
|
||||
const resp = await makeReq('now-playing');
|
||||
if (resp.body === undefined || typeof resp.body !== 'object') {
|
||||
throw new UpstreamError('Bridge returned no JSON payload', { responseBody: resp.body, showStopper: true });
|
||||
}
|
||||
const body = resp.body as any;
|
||||
const single = body?.data ?? null;
|
||||
return {
|
||||
ok: body?.ok ?? true,
|
||||
source: body?.source,
|
||||
fresh: body?.fresh,
|
||||
players_count: single ? 1 : 0,
|
||||
primary: single,
|
||||
data: single ? [single] : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private resetSyntheticPlayback() {
|
||||
this.syntheticPlayback = undefined;
|
||||
this.lastBridgeData = undefined;
|
||||
this.lastPlay = undefined;
|
||||
this.lastBridgeSeenAtMs = undefined;
|
||||
private getPlayerId(bridgeData: BridgeTrackData): string {
|
||||
const raw = bridgeData.player_id ?? bridgeData.device_id ?? bridgeData.queue_id ?? bridgeData.track_id;
|
||||
return raw !== undefined && raw !== null && `${raw}`.trim() !== '' ? `${raw}` : NO_DEVICE;
|
||||
}
|
||||
|
||||
private isStationLocal(bridgeData: BridgeTrackData): boolean {
|
||||
return bridgeData.source === 'station-local';
|
||||
}
|
||||
|
||||
private removePlayerState(playerId: string): void {
|
||||
this.syntheticPlaybackByPlayer.delete(playerId);
|
||||
this.lastBridgeDataByPlayer.delete(playerId);
|
||||
this.lastPlayByPlayer.delete(playerId);
|
||||
this.lastBridgeSeenAtMsByPlayer.delete(playerId);
|
||||
}
|
||||
|
||||
private clearSyntheticPlayback(playerId: string): void {
|
||||
this.syntheticPlaybackByPlayer.delete(playerId);
|
||||
}
|
||||
|
||||
private getSyntheticKey(bridgeData: BridgeTrackData, play: PlayObject): string {
|
||||
@@ -128,7 +204,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
|
||||
? bridgeData.artists_list.join(',')
|
||||
: (bridgeData.artists ?? play.data.artists?.join(',') ?? '');
|
||||
return [
|
||||
bridgeData.queue_id ?? '',
|
||||
this.getPlayerId(bridgeData),
|
||||
bridgeData.track_id ?? '',
|
||||
bridgeData.title ?? play.data.track ?? '',
|
||||
artists,
|
||||
@@ -136,55 +212,58 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
|
||||
].join('::');
|
||||
}
|
||||
|
||||
private getPlaybackState(
|
||||
bridgeData: BridgeTrackData,
|
||||
play: PlayObject,
|
||||
options: { keepAlive?: boolean } = {},
|
||||
): { status: typeof REPORTED_PLAYER_STATUSES.playing, position: number } {
|
||||
const { keepAlive = false } = options;
|
||||
private getPlaybackState(playerId: string, bridgeData: BridgeTrackData, play: PlayObject): { status: typeof REPORTED_PLAYER_STATUSES.playing, position: number } {
|
||||
const now = Date.now();
|
||||
const reportedPositionSec = bridgeData.progress_ms !== undefined && bridgeData.progress_ms !== null
|
||||
? Math.max(0, bridgeData.progress_ms / 1000)
|
||||
: undefined;
|
||||
const durationSec = play.data.duration;
|
||||
const key = this.getSyntheticKey(bridgeData, play);
|
||||
|
||||
if (this.syntheticPlayback === undefined || this.syntheticPlayback.key !== key) {
|
||||
if (this.isStationLocal(bridgeData)) {
|
||||
const position = durationSec !== undefined && durationSec > 0
|
||||
? Math.min(reportedPositionSec ?? play.meta.trackProgressPosition ?? 0, durationSec)
|
||||
: (reportedPositionSec ?? play.meta.trackProgressPosition ?? 0);
|
||||
this.clearSyntheticPlayback(playerId);
|
||||
return {
|
||||
status: REPORTED_PLAYER_STATUSES.playing,
|
||||
position: Math.max(0, position),
|
||||
};
|
||||
}
|
||||
const key = this.getSyntheticKey(bridgeData, play);
|
||||
const current = this.syntheticPlaybackByPlayer.get(playerId);
|
||||
|
||||
if (current === undefined || current.key !== key) {
|
||||
const initialPosition = reportedPositionSec ?? play.meta.trackProgressPosition ?? 0;
|
||||
this.syntheticPlayback = {
|
||||
this.syntheticPlaybackByPlayer.set(playerId, {
|
||||
key,
|
||||
lastSeenAtMs: now,
|
||||
lastPositionSec: initialPosition,
|
||||
durationSec,
|
||||
reachedTrackEndAtMs: durationSec !== undefined && durationSec > 0 && initialPosition >= durationSec ? now : undefined,
|
||||
};
|
||||
});
|
||||
return {
|
||||
status: REPORTED_PLAYER_STATUSES.playing,
|
||||
position: durationSec !== undefined && durationSec > 0 ? Math.min(initialPosition, durationSec) : initialPosition,
|
||||
};
|
||||
}
|
||||
|
||||
const elapsedSec = Math.max(0, (now - this.syntheticPlayback.lastSeenAtMs) / 1000);
|
||||
const elapsedSec = Math.max(0, (now - current.lastSeenAtMs) / 1000);
|
||||
const startingPoint = reportedPositionSec !== undefined
|
||||
? Math.max(reportedPositionSec, this.syntheticPlayback.lastPositionSec)
|
||||
: this.syntheticPlayback.lastPositionSec;
|
||||
? Math.max(reportedPositionSec, current.lastPositionSec)
|
||||
: current.lastPositionSec;
|
||||
|
||||
let nextPosition = startingPoint + elapsedSec;
|
||||
|
||||
if (durationSec !== undefined && durationSec > 0) {
|
||||
// Never let synthetic time run past track duration. Once it reaches the end
|
||||
// we will keep the player alive briefly and then emit a synthetic STOP.
|
||||
if (nextPosition >= durationSec) {
|
||||
nextPosition = durationSec;
|
||||
if (this.syntheticPlayback.reachedTrackEndAtMs === undefined) {
|
||||
this.syntheticPlayback.reachedTrackEndAtMs = now;
|
||||
}
|
||||
if (durationSec !== undefined && durationSec > 0 && nextPosition >= durationSec) {
|
||||
nextPosition = durationSec;
|
||||
if (current.reachedTrackEndAtMs === undefined) {
|
||||
current.reachedTrackEndAtMs = now;
|
||||
}
|
||||
}
|
||||
|
||||
this.syntheticPlayback.lastSeenAtMs = now;
|
||||
this.syntheticPlayback.lastPositionSec = nextPosition;
|
||||
this.syntheticPlayback.durationSec = durationSec;
|
||||
current.lastSeenAtMs = now;
|
||||
current.lastPositionSec = nextPosition;
|
||||
current.durationSec = durationSec;
|
||||
this.syntheticPlaybackByPlayer.set(playerId, current);
|
||||
|
||||
return {
|
||||
status: REPORTED_PLAYER_STATUSES.playing,
|
||||
@@ -192,14 +271,22 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
|
||||
};
|
||||
}
|
||||
|
||||
private shouldKeepAliveSynthetic(nowMs: number = Date.now()): boolean {
|
||||
if (this.syntheticPlayback === undefined || this.lastPlay === undefined || this.lastBridgeData === undefined || this.lastBridgeSeenAtMs === undefined) {
|
||||
private shouldKeepAliveSynthetic(playerId: string, nowMs: number = Date.now()): boolean {
|
||||
const lastBridgeData = this.lastBridgeDataByPlayer.get(playerId);
|
||||
if (lastBridgeData !== undefined && this.isStationLocal(lastBridgeData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const silenceSec = Math.max(0, (nowMs - this.lastBridgeSeenAtMs) / 1000);
|
||||
const durationSec = this.syntheticPlayback.durationSec ?? this.lastPlay.data.duration;
|
||||
const currentPosSec = this.syntheticPlayback.lastPositionSec ?? this.lastPlay.meta.trackProgressPosition ?? 0;
|
||||
const synthetic = this.syntheticPlaybackByPlayer.get(playerId);
|
||||
const lastPlay = this.lastPlayByPlayer.get(playerId);
|
||||
const lastSeen = this.lastBridgeSeenAtMsByPlayer.get(playerId);
|
||||
if (synthetic === undefined || lastPlay === undefined || lastBridgeData === undefined || lastSeen === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const silenceSec = Math.max(0, (nowMs - lastSeen) / 1000);
|
||||
const durationSec = synthetic.durationSec ?? lastPlay.data.duration;
|
||||
const currentPosSec = synthetic.lastPositionSec ?? lastPlay.meta.trackProgressPosition ?? 0;
|
||||
|
||||
let allowedSilenceSec = this.keepAliveMinSec;
|
||||
if (durationSec !== undefined && durationSec > 0) {
|
||||
@@ -212,103 +299,138 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.debug(`Dropping synthetic keepalive after ${silenceSec.toFixed(0)}s without bridge data (allowed ${allowedSilenceSec.toFixed(0)}s)`);
|
||||
this.logger.debug(`Dropping synthetic keepalive for player ${playerId} after ${silenceSec.toFixed(0)}s without bridge data (allowed ${allowedSilenceSec.toFixed(0)}s)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
private shouldFinalizeSyntheticTrack(nowMs: number = Date.now()): boolean {
|
||||
if (this.syntheticPlayback === undefined || this.lastPlay === undefined) {
|
||||
private shouldFinalizeSyntheticTrack(playerId: string, nowMs: number = Date.now()): boolean {
|
||||
const synthetic = this.syntheticPlaybackByPlayer.get(playerId);
|
||||
const lastPlay = this.lastPlayByPlayer.get(playerId);
|
||||
if (synthetic === undefined || lastPlay === undefined) {
|
||||
return false;
|
||||
}
|
||||
const durationSec = this.syntheticPlayback.durationSec ?? this.lastPlay.data.duration;
|
||||
const durationSec = synthetic.durationSec ?? lastPlay.data.duration;
|
||||
if (durationSec === undefined || durationSec <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.syntheticPlayback.lastPositionSec < durationSec) {
|
||||
if (synthetic.lastPositionSec < durationSec) {
|
||||
return false;
|
||||
}
|
||||
if (this.syntheticPlayback.reachedTrackEndAtMs === undefined) {
|
||||
this.syntheticPlayback.reachedTrackEndAtMs = nowMs;
|
||||
if (synthetic.reachedTrackEndAtMs === undefined) {
|
||||
synthetic.reachedTrackEndAtMs = nowMs;
|
||||
this.syntheticPlaybackByPlayer.set(playerId, synthetic);
|
||||
return false;
|
||||
}
|
||||
const sinceEndSec = Math.max(0, (nowMs - this.syntheticPlayback.reachedTrackEndAtMs) / 1000);
|
||||
const sinceEndSec = Math.max(0, (nowMs - synthetic.reachedTrackEndAtMs) / 1000);
|
||||
return sinceEndSec >= this.postEndStopGraceSec;
|
||||
}
|
||||
|
||||
private buildStoppedState(): PlayerStateDataMaybePlay | undefined {
|
||||
if (this.lastBridgeData === undefined) {
|
||||
private buildStoppedState(playerId: string): PlayerStateDataMaybePlay | undefined {
|
||||
const bridgeData = this.lastBridgeDataByPlayer.get(playerId);
|
||||
if (bridgeData === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
platformId: [NO_DEVICE, NO_USER],
|
||||
sessionId: this.lastBridgeData.queue_id ?? this.lastBridgeData.track_id,
|
||||
platformId: [playerId, NO_USER],
|
||||
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
|
||||
status: REPORTED_PLAYER_STATUSES.stopped,
|
||||
};
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
const payload = await this.callBridge();
|
||||
const bridgeData = payload.data;
|
||||
const rawData = payload.data;
|
||||
const players = Array.isArray(rawData)
|
||||
? rawData.filter((x): x is BridgeTrackData => x !== null && x !== undefined && typeof x === 'object')
|
||||
: (rawData !== undefined && rawData !== null && typeof rawData === 'object' ? [rawData as BridgeTrackData] : []);
|
||||
const nowMs = Date.now();
|
||||
const seenPlayerIds = new Set<string>();
|
||||
const states: PlayerStateDataMaybePlay[] = [];
|
||||
|
||||
if (payload.ok && bridgeData !== undefined && bridgeData !== null && bridgeData.title) {
|
||||
const play = formatPlayObj(bridgeData);
|
||||
const playbackState = this.getPlaybackState(bridgeData, play);
|
||||
this.lastBridgeData = bridgeData;
|
||||
this.lastPlay = play;
|
||||
this.lastBridgeSeenAtMs = nowMs;
|
||||
for (const rawBridgeData of players) {
|
||||
const bridgeData = normalizeBridgeData(rawBridgeData);
|
||||
if (!payload.ok || bridgeData.title === undefined || bridgeData.title === null || `${bridgeData.title}`.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
const playerId = this.getPlayerId(bridgeData);
|
||||
seenPlayerIds.add(playerId);
|
||||
const play = formatPlayObj(bridgeData, playerId);
|
||||
this.lastBridgeDataByPlayer.set(playerId, bridgeData);
|
||||
this.lastPlayByPlayer.set(playerId, play);
|
||||
this.lastBridgeSeenAtMsByPlayer.set(playerId, nowMs);
|
||||
|
||||
if (this.shouldFinalizeSyntheticTrack(nowMs)) {
|
||||
const durationSec = this.syntheticPlayback?.durationSec ?? play.data.duration ?? 0;
|
||||
this.logger.info(`Synthetic playback exceeded track duration for '${play.data.artists?.join(', ') ?? 'Unknown'} - ${play.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
|
||||
const stoppedState = this.buildStoppedState();
|
||||
this.resetSyntheticPlayback();
|
||||
return await this.processRecentPlays(stoppedState !== undefined ? [stoppedState] : []);
|
||||
if (this.isStationLocal(bridgeData) && bridgeData.paused === true) {
|
||||
this.clearSyntheticPlayback(playerId);
|
||||
states.push({
|
||||
platformId: [playerId, NO_USER],
|
||||
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
|
||||
status: REPORTED_PLAYER_STATUSES.paused,
|
||||
play,
|
||||
position: getSafePositionSec(bridgeData) ?? play.meta.trackProgressPosition ?? 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const playerState: PlayerStateData = {
|
||||
platformId: [NO_DEVICE, NO_USER],
|
||||
sessionId: bridgeData.queue_id ?? bridgeData.track_id,
|
||||
const playbackState = this.getPlaybackState(playerId, bridgeData, play);
|
||||
|
||||
if (this.shouldFinalizeSyntheticTrack(playerId, nowMs)) {
|
||||
const durationSec = this.syntheticPlaybackByPlayer.get(playerId)?.durationSec ?? play.data.duration ?? 0;
|
||||
this.logger.info(`Synthetic playback exceeded track duration for player ${playerId} '${play.data.artists?.join(', ') ?? 'Unknown'} - ${play.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
|
||||
const stoppedState = this.buildStoppedState(playerId);
|
||||
this.removePlayerState(playerId);
|
||||
if (stoppedState !== undefined) {
|
||||
states.push(stoppedState);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
states.push({
|
||||
platformId: [playerId, NO_USER],
|
||||
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
|
||||
status: playbackState.status,
|
||||
play,
|
||||
position: playbackState.position,
|
||||
};
|
||||
|
||||
return await this.processRecentPlays([playerState]);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.shouldKeepAliveSynthetic(nowMs)) {
|
||||
const bridgeDataForKeepAlive = this.lastBridgeData!;
|
||||
const playForKeepAlive = this.lastPlay!;
|
||||
const playbackState = this.getPlaybackState(bridgeDataForKeepAlive, playForKeepAlive, { keepAlive: true });
|
||||
for (const [playerId, lastPlay] of Array.from(this.lastPlayByPlayer.entries())) {
|
||||
if (seenPlayerIds.has(playerId)) {
|
||||
continue;
|
||||
}
|
||||
if (this.shouldKeepAliveSynthetic(playerId, nowMs)) {
|
||||
const bridgeDataForKeepAlive = this.lastBridgeDataByPlayer.get(playerId)!;
|
||||
const playbackState = this.getPlaybackState(playerId, bridgeDataForKeepAlive, lastPlay);
|
||||
|
||||
if (this.shouldFinalizeSyntheticTrack(nowMs)) {
|
||||
const durationSec = this.syntheticPlayback?.durationSec ?? playForKeepAlive.data.duration ?? 0;
|
||||
this.logger.info(`Synthetic keepalive exceeded track duration for '${playForKeepAlive.data.artists?.join(', ') ?? 'Unknown'} - ${playForKeepAlive.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
|
||||
const stoppedState = this.buildStoppedState();
|
||||
this.resetSyntheticPlayback();
|
||||
return await this.processRecentPlays(stoppedState !== undefined ? [stoppedState] : []);
|
||||
if (this.shouldFinalizeSyntheticTrack(playerId, nowMs)) {
|
||||
const durationSec = this.syntheticPlaybackByPlayer.get(playerId)?.durationSec ?? lastPlay.data.duration ?? 0;
|
||||
this.logger.info(`Synthetic keepalive exceeded track duration for player ${playerId} '${lastPlay.data.artists?.join(', ') ?? 'Unknown'} - ${lastPlay.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
|
||||
const stoppedState = this.buildStoppedState(playerId);
|
||||
this.removePlayerState(playerId);
|
||||
if (stoppedState !== undefined) {
|
||||
states.push(stoppedState);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.trace(`Bridge returned no current track for player ${playerId}; keeping synthetic playback alive for ${lastPlay.data.artists?.join(', ') ?? 'Unknown'} - ${lastPlay.data.track ?? 'Unknown'}`);
|
||||
states.push({
|
||||
platformId: [playerId, NO_USER],
|
||||
sessionId: bridgeDataForKeepAlive.queue_id ?? bridgeDataForKeepAlive.track_id ?? playerId,
|
||||
status: REPORTED_PLAYER_STATUSES.playing,
|
||||
play: lastPlay,
|
||||
position: playbackState.position,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.trace(`Bridge returned no current track; keeping synthetic playback alive for ${playForKeepAlive.data.artists?.join(', ') ?? 'Unknown'} - ${playForKeepAlive.data.track ?? 'Unknown'}`);
|
||||
|
||||
const playerState: PlayerStateData = {
|
||||
platformId: [NO_DEVICE, NO_USER],
|
||||
sessionId: bridgeDataForKeepAlive.queue_id ?? bridgeDataForKeepAlive.track_id,
|
||||
status: REPORTED_PLAYER_STATUSES.playing,
|
||||
play: playForKeepAlive,
|
||||
position: playbackState.position,
|
||||
};
|
||||
|
||||
return await this.processRecentPlays([playerState]);
|
||||
this.removePlayerState(playerId);
|
||||
}
|
||||
|
||||
this.resetSyntheticPlayback();
|
||||
return await this.processRecentPlays([]);
|
||||
return await this.processRecentPlays(states);
|
||||
}
|
||||
}
|
||||
|
||||
const formatPlayObj = (obj: BridgeTrackData): PlayObject => {
|
||||
const formatPlayObj = (obj: BridgeTrackData, playerId: string): PlayObject => {
|
||||
const artists = Array.isArray(obj.artists_list) && obj.artists_list.length > 0
|
||||
? obj.artists_list
|
||||
: (obj.artists ? obj.artists.split(/\s*,\s*/).filter(x => x.trim() !== '') : []);
|
||||
@@ -323,10 +445,10 @@ const formatPlayObj = (obj: BridgeTrackData): PlayObject => {
|
||||
: undefined,
|
||||
},
|
||||
meta: {
|
||||
trackProgressPosition: obj.progress_ms !== undefined && obj.progress_ms !== null ? obj.progress_ms / 1000 : undefined,
|
||||
deviceId: NO_DEVICE,
|
||||
mediaPlayerName: 'Yandex Music',
|
||||
mediaPlayerVersion: 'bridge',
|
||||
trackProgressPosition: getSafePositionSec(obj),
|
||||
deviceId: playerId || NO_DEVICE,
|
||||
mediaPlayerName: obj.device_name ?? (obj.source === 'station-local' ? 'Yandex Station' : 'Yandex Music'),
|
||||
mediaPlayerVersion: obj.platform ?? (obj.source === 'station-local' ? 'station-local' : 'bridge'),
|
||||
comment: obj.track_id !== undefined ? `Yandex Track ${obj.track_id}` : undefined,
|
||||
art: obj.cover !== undefined && obj.cover !== null && obj.cover !== '' ? { album: obj.cover } : undefined,
|
||||
}
|
||||
|
||||
@@ -156,17 +156,17 @@ describe('#Caching', function () {
|
||||
|
||||
it('Preserves scrobbles', async function () {
|
||||
|
||||
this.timeout(10000);
|
||||
this.timeout(100000);
|
||||
|
||||
// why does this take so long?
|
||||
await withLocalTmpDir(async () => {
|
||||
|
||||
const root = getRoot();
|
||||
root.upsert({ cache: () => () => new MSCache(loggerTest, { scrobble: { provider: 'file', connection: process.cwd(), persistInterval: 100 } }) });
|
||||
root.upsert({ cache: () => () => new MSCache(loggerTest, { scrobble: { provider: 'file', connection: process.cwd(), persistInterval: 100 }, auth: {provider: 'memory'}, metadata: {provider: 'memory'} }) });
|
||||
|
||||
await using test = new TestScrobbler();
|
||||
await test.initialize();
|
||||
const plays = generatePlays(100);
|
||||
const plays = generatePlays(100, {}, {}, {listenRanges: true});
|
||||
await test.queueScrobble(plays, 'testSource');
|
||||
const queued = test.queuedScrobbles.map(x => x.play);
|
||||
await sleep(101);
|
||||
|
||||
@@ -500,6 +500,7 @@ const CA_MOCK_URL_STR = 'http://coverartarchive.org';
|
||||
const CA_MOCK_URL = new URL(CA_MOCK_URL_STR);
|
||||
const RELEASE = '76df3287-6cda-33eb-8e9a-044b5e15ffdd';
|
||||
const INVALID_RELEASE = '76df3287-6cda-33eb-8e9a-044b5e15ffde';
|
||||
const RELEASE_GROUP = 'dc7bec45-b321-4dfc-be66-96eab5acb36e';
|
||||
|
||||
describe('#CoverArt CoverArtArchive API', function () {
|
||||
it('Should get url from response',
|
||||
@@ -515,7 +516,7 @@ describe('#CoverArt CoverArtArchive API', function () {
|
||||
],
|
||||
async function () {
|
||||
const api = createCoverArtApi({url: CA_MOCK_URL});
|
||||
const resp = await api.getCoverThumb(RELEASE, {size: 250});
|
||||
const resp = await api.getCoverThumb(RELEASE, 'release', {size: 250});
|
||||
expect(resp).is.not.undefined;
|
||||
expect(resp).eq(`${CA_MOCK_URL_STR}/cdn/1.jpg`)
|
||||
}
|
||||
@@ -531,7 +532,7 @@ describe('#CoverArt CoverArtArchive API', function () {
|
||||
],
|
||||
async function () {
|
||||
const api = createCoverArtApi({url: CA_MOCK_URL});
|
||||
const resp = await api.getCoverThumb(INVALID_RELEASE, {size: 250});
|
||||
const resp = await api.getCoverThumb(INVALID_RELEASE, 'release', {size: 250});
|
||||
expect(resp).is.undefined;
|
||||
}
|
||||
));
|
||||
@@ -544,10 +545,15 @@ describe('#CoverArt CoverArtArchive API', function () {
|
||||
}
|
||||
});
|
||||
|
||||
it('Returns a response', async function() {
|
||||
it('Returns a response for release', async function() {
|
||||
const api = createCoverArtApi();
|
||||
const resp = await api.getCoverThumb(RELEASE, {size: 250});
|
||||
const resp = await api.getCoverThumb(RELEASE, 'release', {size: 250});
|
||||
expect(resp).is.not.undefined;
|
||||
});
|
||||
it('Returns a response for release-group', async function() {
|
||||
const api = createCoverArtApi();
|
||||
const resp = await api.getCoverThumb(RELEASE_GROUP, 'release-group', {size: 250});
|
||||
expect(resp).is.not.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,7 @@ describe('Player Cleanup', function () {
|
||||
const cleanedUpDuration = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
await using 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});
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([initialState])).length).to.be.eq(0);
|
||||
|
||||
let position = 0;
|
||||
@@ -207,7 +207,7 @@ describe('Player Cleanup', function () {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('Player Cleanup', function () {
|
||||
|
||||
await using 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});
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([initialState])).length).to.be.eq(0);
|
||||
|
||||
let position = 0;
|
||||
@@ -251,7 +251,7 @@ describe('Player Cleanup', function () {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ describe('Player Cleanup', function () {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('Player Cleanup', function () {
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
// new Play
|
||||
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
const advancedState = generatePlayerStateData({stateUpdatedAt: 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 = await source.processRecentPlays([advancedState])
|
||||
expect(plays.length).to.be.eq(0);
|
||||
@@ -309,7 +309,7 @@ describe('Player Cleanup', function () {
|
||||
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});
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([initialState])).length).to.be.eq(0);
|
||||
|
||||
let position = 0;
|
||||
@@ -321,7 +321,7 @@ describe('Player Cleanup', function () {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ describe('Player Cleanup', function () {
|
||||
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});
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([initialState])).length).to.be.eq(0);
|
||||
|
||||
let position = 0;
|
||||
@@ -366,7 +366,7 @@ describe('Player Cleanup', function () {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
@@ -394,17 +394,17 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 5; i++) {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
// new Play
|
||||
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
const advancedState = generatePlayerStateData({stateUpdatedAt: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
// should return discovered play with ~90 seconds of duration
|
||||
const plays = await source.processRecentPlays([advancedState])
|
||||
expect(plays.length).to.be.eq(1);
|
||||
|
||||
@@ -62,7 +62,7 @@ export function staggerMapper<Element, NewElement>(options: StaggerOptions) {
|
||||
|
||||
return (mapper: Mapper<Element, NewElement>) => async (x: Element, index: number) => {
|
||||
if (index < concurrency) {
|
||||
sleep(initialStagger);
|
||||
await sleep(initialStagger);
|
||||
initialStagger += initialInterval;
|
||||
} else {
|
||||
const s = Math.min((Math.random() * 1000), maxRandomStagger)
|
||||
|
||||
@@ -20,10 +20,9 @@ export const rehydratePlay = (obj: AmbPlayObject): PlayObject => {
|
||||
if(obj.data.playDateCompleted !== undefined) {
|
||||
obj.data.playDateCompleted = dayjs(obj.data.playDateCompleted);
|
||||
}
|
||||
|
||||
if(obj.data.listenRanges !== undefined) {
|
||||
obj.data.listenRanges = obj.data.listenRanges.map(rehydrateListenRangeData);
|
||||
}
|
||||
}
|
||||
if(obj.data.listenRanges !== undefined) {
|
||||
obj.data.listenRanges = obj.data.listenRanges.map(rehydrateListenRangeData).filter(x => x !== undefined);
|
||||
}
|
||||
return obj as PlayObject;
|
||||
}
|
||||
@@ -31,6 +30,10 @@ export const rehydratePlay = (obj: AmbPlayObject): PlayObject => {
|
||||
// this may become problematic since we aren't re-instantiating Progress class, just implementing interface
|
||||
// but that may only be an issue if rehydrating source data which isn't in scope so far
|
||||
export const rehydrateListenRangeData = (obj: ListenRangeDataAmb): ListenRangeData => {
|
||||
// workaround for incompatible rehydration
|
||||
if(obj.start === undefined || obj.end === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
start: rehydratePlayProgress(obj.start),
|
||||
end: rehydratePlayProgress(obj.end)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import JSON5 from "json5";
|
||||
import { constants, promises } from "fs";
|
||||
import { MaybeLogger } from '../common/MaybeLogger.js';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
export const asArray = <T>(data: T | T[]): T[] => {
|
||||
if (Array.isArray(data)) {
|
||||
@@ -166,4 +167,12 @@ export const shuffleArray = (array: any[]): void => {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
|
||||
export const objectsEqual = (a: object, b: object) => {
|
||||
try {
|
||||
return deepEqual(a, b);
|
||||
} catch (e) {
|
||||
throw new Error('Could not compare objects', {cause: e});
|
||||
}
|
||||
}
|
||||
@@ -453,4 +453,20 @@ export const hasNonAlphanumericChars = (str: string): boolean => {
|
||||
return NON_ALPHANUMWHITESPACE_CHARS.test(str);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Produces a deterministic number from a string, DJB2 Hash
|
||||
*
|
||||
* Not cryptographically secure or probably good for avoiding collisions
|
||||
* but we're only using it for like a set of < 10 to make deterministic user ids for listenbrainz spec
|
||||
*
|
||||
* @see https://www.xjavascript.com/blog/javascript-generate-unique-number-based-on-string/
|
||||
*/
|
||||
export const stringToDeterministicNumber = (str: string): number => {
|
||||
let hash = 5381; // Initial value
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
// hash * 33 + charCode (bitwise shift for efficiency: hash << 5 is hash * 32)
|
||||
hash = (hash << 5) + hash + str.charCodeAt(i);
|
||||
}
|
||||
// Convert to unsigned 32-bit integer to avoid negative values
|
||||
return hash >>> 0;
|
||||
}
|
||||
@@ -6,9 +6,8 @@ import { ErrorAlert } from "./ErrorAlert";
|
||||
import { IoMdCodeDownload } from "react-icons/io";
|
||||
import { BiWrench } from "react-icons/bi";
|
||||
import { IoMusicalNoteOutline } from "react-icons/io5";
|
||||
import { PiMagnifyingGlassDuotone } from "react-icons/pi";
|
||||
import { HiMiniMagnifyingGlass } from "react-icons/hi2";
|
||||
import { TbDatabaseEdit } from "react-icons/tb";
|
||||
import { MdFiberNew } from "react-icons/md";
|
||||
import { capitalize } from "../../core/StringUtils";
|
||||
import { shortTodayAwareFormat, todayAwareFormat } from "../../core/TimeUtils";
|
||||
import dayjs from "dayjs";
|
||||
@@ -156,7 +155,7 @@ export const ActivityTimeline = (props: ActivityDetailProps) => {
|
||||
<Timeline.Separator />
|
||||
<Timeline.Indicator>
|
||||
<Icon fontSize="lg" color={`${match.match ? 'orange' : 'green'}.focusRing`}>
|
||||
<PiMagnifyingGlassDuotone />
|
||||
<HiMiniMagnifyingGlass />
|
||||
</Icon>
|
||||
</Timeline.Indicator>
|
||||
</Timeline.Connector>
|
||||
|
||||
@@ -3,12 +3,9 @@ import { Timeline, Icon, Span, Stack, Heading, Box, Text } from '@chakra-ui/reac
|
||||
import { JsonPlayObject, LifecycleStep } from "../../core/Atomic";
|
||||
import { PlayData } from "./PlayData";
|
||||
import { ErrorAlert } from "./ErrorAlert";
|
||||
import { BiWrench } from "react-icons/bi";
|
||||
import { BsSkipForward } from "react-icons/bs";
|
||||
import { LuCheck, LuCircleX } from "react-icons/lu";
|
||||
import { BsStoplights } from "react-icons/bs";
|
||||
import { BsExclamationTriangle, BsExclamationOctagon } from "react-icons/bs";
|
||||
import { FaStop } from "react-icons/fa";
|
||||
import { MdMusicNote } from "react-icons/md";
|
||||
import { ChakraCodeBlockShort, ChakraPlainBlockShort } from "./CodeBlock";
|
||||
import { JsonDiffPatch } from "./JsonDiff";
|
||||
|
||||
@@ -107,8 +107,8 @@ export interface ListenRangeDataAmb {
|
||||
}
|
||||
|
||||
export interface ListenRangeData extends ListenRangeDataAmb {
|
||||
start: ListenProgress
|
||||
end: ListenProgress
|
||||
start: PlayProgress
|
||||
end: PlayProgress
|
||||
}
|
||||
|
||||
/** https://musicbrainz.org/doc/MusicBrainz_Database/Schema#Overview */
|
||||
@@ -554,6 +554,8 @@ export interface TransformResult {
|
||||
|
||||
export const KNOWN_MEDIA_PROVIDER_URLS = [
|
||||
'spotify.com',
|
||||
// spotify cdn
|
||||
'scdn.co',
|
||||
'bandcamp.com',
|
||||
'youtube.com',
|
||||
'deezer.com',
|
||||
|
||||
@@ -16,6 +16,7 @@ import { nanoid } from 'nanoid';
|
||||
import { LastFMTrackObject } from '../backend/common/vendor/LastfmApiClient.js';
|
||||
import { MarkOptional } from 'ts-essentials';
|
||||
import { defaultLifecycle } from '../backend/utils/PlayTransformUtils.js';
|
||||
import clone from 'clone';
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
@@ -156,16 +157,18 @@ export const generatePlayerStateData = (options: Omit<PlayerStateDataMaybePlay,
|
||||
play,
|
||||
status: options.status,
|
||||
position: options.position,
|
||||
timestamp: options.timestamp ?? dayjs()
|
||||
stateUpdatedAt: options.stateUpdatedAt ?? dayjs()
|
||||
}
|
||||
}
|
||||
|
||||
export interface GeneratePlayOpts {
|
||||
playDateCompleted?: boolean
|
||||
playDateCompleted?: boolean,
|
||||
listenRanges?: boolean
|
||||
}
|
||||
export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}, opts: GeneratePlayOpts = {}): PlayObject => {
|
||||
const {
|
||||
playDateCompleted = false
|
||||
playDateCompleted = false,
|
||||
listenRanges = false,
|
||||
} = opts;
|
||||
|
||||
const duration = faker.number.int({min: 30, max: 300});
|
||||
@@ -201,6 +204,35 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional<PlayM
|
||||
play.data.playDateCompleted = play.data.playDate.add(play.data.duration)
|
||||
}
|
||||
|
||||
if(listenRanges) {
|
||||
play.data.listenRanges = [];
|
||||
const lf = play.data.listenedFor;
|
||||
const sessions = faker.number.int({min: 1, max: 3});
|
||||
const sessionTime = lf / sessions;
|
||||
let nextTime = play.data.playDate;
|
||||
switch(faker.number.int({min: 1, max: 2})) {
|
||||
case 1:
|
||||
// timestamps only
|
||||
for(let i = 0; i < sessions; i++) {
|
||||
const newTime = nextTime.add(sessionTime, 's');
|
||||
play.data.listenRanges.push({start: {timestamp: clone(nextTime)}, end: {timestamp: newTime}});
|
||||
nextTime = newTime;
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
// timestamps + position
|
||||
let position = 0;
|
||||
for(let i = 0; i < sessions; i++) {
|
||||
const newTime = nextTime.add(sessionTime, 's');
|
||||
const nextPosition = position + sessionTime;
|
||||
play.data.listenRanges.push({start: {timestamp: clone(nextTime), position}, end: {timestamp: newTime, position: nextPosition}});
|
||||
nextTime = newTime;
|
||||
position = nextPosition;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return play;
|
||||
}
|
||||
|
||||
@@ -262,8 +294,8 @@ export const generatePlayPlatformId = (deviceId?: string, userId?: string): Play
|
||||
return [did, uid];
|
||||
}
|
||||
|
||||
export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}): PlayObject[] => {
|
||||
return Array.from(Array(numberOfPlays), () => generatePlay(data, meta));
|
||||
export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}, opts: GeneratePlayOpts = {}): PlayObject[] => {
|
||||
return Array.from(Array(numberOfPlays), () => generatePlay(data, meta, opts));
|
||||
}
|
||||
|
||||
export const generateArtist = () => faker.music.artist;
|
||||
|
||||
@@ -32,6 +32,9 @@ export default defineConfig(() => {
|
||||
}
|
||||
console.debug(`[VITE] BASE_URL ENV: ${process.env.BASE_URL} | Base Url String: ${baseUrlStr}`);
|
||||
return {
|
||||
server: {
|
||||
allowedHosts: (true as true)
|
||||
},
|
||||
esbuild: {
|
||||
minifyIdentifiers: false
|
||||
},
|
||||
|
||||