mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0e798c052 | ||
|
|
44424f2570 | ||
|
|
72df520300 | ||
|
|
25c783051d | ||
|
|
264bc31af6 | ||
|
|
e0bdb8fe3c | ||
|
|
4d7c93f7dc | ||
|
|
dd54075c52 | ||
|
|
ff08b6327d | ||
|
|
76392a9668 | ||
|
|
f47faabf7c | ||
|
|
120e280fc8 | ||
|
|
6ae80fd881 | ||
|
|
be70e7074a | ||
|
|
65841a8c6b | ||
|
|
82374fc806 | ||
|
|
004400d7ea | ||
|
|
d54e169a14 | ||
|
|
31c35647dd | ||
|
|
204ee159d6 | ||
|
|
4fe1f8ced9 | ||
|
|
c26e6f77f5 | ||
|
|
e2b59900e5 | ||
|
|
c9cde11871 | ||
|
|
ea0c75ccf3 | ||
|
|
13a64bd63a | ||
|
|
c3b23271d4 | ||
|
|
d047c600c2 | ||
|
|
b6cae4a538 | ||
|
|
1eed48456c | ||
|
|
20834e812e |
+1
-5
@@ -153,8 +153,4 @@ tmp-*
|
||||
storybook-static
|
||||
|
||||
*.db
|
||||
*.db.*
|
||||
*.db-*lib
|
||||
*.db
|
||||
*.db.*
|
||||
*.db-*lib
|
||||
*.db*
|
||||
@@ -9,7 +9,7 @@ import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import JriverConfig from '!!raw-loader!@site/../config/jriver.json.example';
|
||||
|
||||
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can can be in the JRiver GUI:
|
||||
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can be found in the JRiver GUI:
|
||||
|
||||
* Tools -> Options -> Media Network
|
||||
* Check `Use Media Network to share this library...`
|
||||
|
||||
@@ -593,39 +593,6 @@ MS uses [caching](/configuration/#caching) to reduce the number of API calls nee
|
||||
|
||||
**If you are using any Transform stages you should configure [secondary caching](/configuration#secondary-caching)** to increase the cache size and lifetime of cached items. This will also reduce memory usage in MS.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
Add valkey service to your [multi-scrobbler docker compose stack](/installation?runType=docker-compose#docker) and configure MS to use it for metadata:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
# ...
|
||||
environment:
|
||||
# ...
|
||||
// highlight-start
|
||||
- CACHE_METADATA=valkey
|
||||
- CACHE_METADATA_CONN=redis://valkey:6379
|
||||
// highlight-end
|
||||
# ...
|
||||
|
||||
// highlight-start
|
||||
valkey:
|
||||
image: valkey/valkey
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
|
||||
volumes:
|
||||
valkeydata:
|
||||
driver: local
|
||||
// highlight-end
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Examples
|
||||
|
||||
See **Examples** sections in specific Stage docs (also in the sidebar):
|
||||
|
||||
@@ -9,7 +9,7 @@ import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import RequiredUpgrade from "@site/src/components/snippets/_upgrade-required.mdx"
|
||||
|
||||
<RequiredUpgrade before='< 0.14.0' current='0.14.0' old='0.13.2' />
|
||||
{/* <RequiredUpgrade before='< 0.14.0' current='0.14.0' old='0.13.2' /> */}
|
||||
|
||||
## Source/Client IDs
|
||||
|
||||
@@ -75,32 +75,34 @@ Add an `id` to the top-level for each Source/Client configuration, next to `data
|
||||
|
||||
Add an `id` to the top-level for each Source/Client configuration, next to `data`:
|
||||
|
||||
```json title="koito.json"
|
||||
[
|
||||
{
|
||||
"name": "koito-source",
|
||||
"configureAs": "source",
|
||||
"type": "koito",
|
||||
// highlight-start
|
||||
"id": "myKoitoID",
|
||||
// highlight-end
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "admin",
|
||||
"url": "http://192.168.0.100:4110"
|
||||
```json title="config.json"
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"name": "koito-source",
|
||||
"configureAs": "source",
|
||||
"type": "koito",
|
||||
// highlight-start
|
||||
"id": "myKoitoID",
|
||||
// highlight-end
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "admin",
|
||||
"url": "http://192.168.0.100:4110"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::note[Default ID]
|
||||
:::important[Default ID]
|
||||
|
||||
If you do not add an ID then Multi-Scrobbler will automatically use the **name** of the Source/Client as the ID. The name is shown in the Dashboard.
|
||||
**If you do not add an ID then Multi-Scrobbler will automatically use the name of the Source/Client as the ID.** The name is shown in the Dashboard.
|
||||
|
||||
If you decide to add an ID later you must use the name as the ID in order to keep Plays/Scobbles associated with the same config.
|
||||
If you decide to add an ID later, you must use the Source/Client name as the ID in order to keep Plays/Scobbles associated with the same config.
|
||||
|
||||
:::
|
||||
|
||||
@@ -108,13 +110,19 @@ If you decide to add an ID later you must use the name as the ID in order to kee
|
||||
|
||||
:::tip
|
||||
|
||||
Before upgrading, if your MS dashboard shows 0 queued/failed for all [Scrobble Clients](/configuration/clients) then you can **ignore this step.**
|
||||
Before upgrading, if your MS dashboard shows 0 queued/failed for all [Scrobble Clients](/configuration/clients) then you can **ignore this section.**
|
||||
|
||||
:::
|
||||
|
||||
:::important
|
||||
|
||||
The cached scrobbles for a Client/Source were based on its **name**. If you plan on changing the name of a Client/Source with this upgrade you should do it **after** this migration has run or else MS will not be able to find any associated cached scrobbles.
|
||||
|
||||
:::
|
||||
|
||||
MS `0.14.0` introduces a database for persisting Plays/Scrobbles, queues, and other data. Prior to `0.14.0`, queued/failed scrobbles were stored in a cache **file** inside your [`CONFIG_DIR`](/installation/?dockerSetting=storage#recommended-settings) named `ms-scrobble.cache`.
|
||||
|
||||
When a [Client](/configuration/clients) is created and **enabled** in `0.14.0` it will attempt to automatically migrate scrobbles from this cache into the database.
|
||||
When a [Client](/configuration/clients) is created and **enabled** in `0.14.0` it will attempt to **automatically migrate scrobbles** from this cache into the database.
|
||||
|
||||
* You can see progress/results for this process by filtering logs for `[Cached Scrobble Migration]`
|
||||
* If the process fails MS will attempt to migrate failed scrobbles, again, the next time it is started
|
||||
@@ -140,7 +148,7 @@ In `0.14.0` [Cache](/configuration#caching) has been simplified with much of the
|
||||
|
||||
This only applies to users who have `cache` in their [AIO Config](/configuration/?configType=aio#configuration-types) `config.json` or are using `CACHE_*` [ENV Config](/configuration/?configType=env#configuration-types)
|
||||
|
||||
If you do not have any of the above [Cache](/configuration#caching) configuration defined then you can **ignore this step.**
|
||||
If you do not have any of the above [Cache](/configuration#caching) configuration defined then you can **ignore this section.**
|
||||
|
||||
:::
|
||||
|
||||
@@ -210,9 +218,41 @@ Remove the `connection` property from the `auth` object. If using `file` the ent
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Renamed ENV Keys
|
||||
## Renamed ENV Keys
|
||||
|
||||
In an effort to standard ENV prefixes some ENV keys have been renamed:
|
||||
|
||||
* `LZENDPOINT_ENABLE` => `LZE_ENABLE`
|
||||
* `LFMENDPOINT_ENABLE` => `LFM_ENABLE`
|
||||
* `LFMENDPOINT_ENABLE` => `LFM_ENABLE`
|
||||
|
||||
## (Optional) ENV Client/Source Names
|
||||
|
||||
In the MS Dashboard, prior to 0.14.0, UI Client/Sources configured with [ENV Config](/configuration/?configType=env#configuration-types) always had generated names like `unnamed-koito`.
|
||||
|
||||
ENV Config Clients/Sources can now be configured with a name by using the `_NAME` suffix, example:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
environment:
|
||||
- KOTIO_TOKEN=...
|
||||
# ...
|
||||
- KOITO_ID=myKoitoID
|
||||
// highlight-start
|
||||
- KOITO_NAME=Koito A
|
||||
// highlight-end
|
||||
|
||||
- JELLYFIN_URL=...
|
||||
# ..
|
||||
- JELLYFIN_ID=myJellyfinID
|
||||
// highlight-start
|
||||
- JELLYFIN_NAME=Foxx Jelly
|
||||
// highlight-end
|
||||
```
|
||||
|
||||
:::important
|
||||
|
||||
If you have **cached scrobbles** make sure to upgrade and run [Cached Scrobble Migration](#cached-scrobble-migration) **before** adding names to Client/Sources.
|
||||
|
||||
:::
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-color-important-contrast-background: #bb91ff;
|
||||
--ifm-color-important-contrast-background: #e7d7ff;
|
||||
--ifm-color-important-highlight: #b94be8;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -28,6 +29,7 @@
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--ifm-color-important-contrast-background: #5a3795;
|
||||
--ifm-color-important-highlight: #e09cfd;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@@ -40,7 +42,7 @@ details[class^='details_'].alert--info {
|
||||
|
||||
margin-bottom: var(--ifm-paragraph-margin-bottom);
|
||||
|
||||
&:not(.alert--warning,.alert--success,.alert--secondary,.alert--danger) {
|
||||
&:not(.alert--warning,.alert--success,.alert--secondary,.alert--danger,.alert--important) {
|
||||
--ifm-alert-background-color: transparent;
|
||||
|
||||
> summary {
|
||||
@@ -98,6 +100,12 @@ details[class^='details_'].alert--info {
|
||||
|
||||
}
|
||||
|
||||
.alert--info.theme-admonition-important {
|
||||
--ifm-alert-background-color: var(--ifm-color-important-contrast-background);
|
||||
--ifm-alert-foreground-color: var(--ifm-color-secondary-contrast-foreground);
|
||||
--ifm-alert-border-color: var(--ifm-color-important-highlight);
|
||||
}
|
||||
|
||||
/** https://stackoverflow.com/a/79808430/1469797 */
|
||||
img.image-center {
|
||||
display: block;
|
||||
|
||||
Generated
+4658
-3533
File diff suppressed because it is too large
Load Diff
+4
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
@@ -64,9 +64,10 @@
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@keyv/valkey": "^1.0.8",
|
||||
"@lukehagar/plexjs": "^0.39.0",
|
||||
"@pierre/diffs": "^1.2.4",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
"@svrooij/sonos": "^2.5.0",
|
||||
"@xhayper/discord-rpc": "^1.3.0",
|
||||
"@xhayper/discord-rpc": "1.3.0",
|
||||
"abort-controller-x": "^0.5.0",
|
||||
"address": "^1.2.2",
|
||||
"ajv": "^8.18.0",
|
||||
@@ -128,7 +129,7 @@
|
||||
"superagent": "^8.0.9",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"tough-cookie": "^5.1.2",
|
||||
"ts-json-schema-generator": "^2.3.0",
|
||||
"ts-json-schema-generator": "2.3.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typeson": "^9.0.4",
|
||||
"typeson-registry": "^11.1.1",
|
||||
@@ -190,7 +191,6 @@
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"eslint-plugin-storybook": "^10.3.6",
|
||||
"git-cliff": "^2.12.0",
|
||||
"jsondiffpatch-react": "^1.0.8",
|
||||
"mocha": "^10.3.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"msw": "^2.12.10",
|
||||
@@ -234,10 +234,6 @@
|
||||
},
|
||||
"musicbrainz-api": {
|
||||
"rate-limit-threshold": "0.2.1"
|
||||
},
|
||||
"jsondiffpatch-react": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface TealClientAIOConfig extends TealClientConfig {
|
||||
}
|
||||
export interface TealArtistCredit {
|
||||
artistName?: string,
|
||||
/** The MusicBrainz artist ID URI, formatted as mbid:<uuid> */
|
||||
artistMbId?: string
|
||||
}
|
||||
/**
|
||||
@@ -70,12 +71,19 @@ export interface ScrobbleRecord {
|
||||
/** A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)` */
|
||||
submissionClientAgent: string,
|
||||
musicServiceBaseDomain?: string
|
||||
// musicbrainz
|
||||
/** The MusicBrainz ID URI of the track, formatted as mbid:<uuid> */
|
||||
trackMbId?: string
|
||||
/** The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid> */
|
||||
recordingMbId?: string
|
||||
/** The MusicBrainz release ID URI, formatted as mbid:<uuid> */
|
||||
releaseMbId?: string
|
||||
isrc?: string,
|
||||
/** The URL associated with this track */
|
||||
originUrl?: string
|
||||
/** Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities. */
|
||||
trackDiscriminant?: string;
|
||||
/** Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities. */
|
||||
releaseDiscriminant?: string;
|
||||
[x: string]: unknown
|
||||
}
|
||||
|
||||
|
||||
+42
-11
@@ -4,7 +4,7 @@ import { ListRecord, ScrobbleRecord, StatusRecord, TealClientData } from "../../
|
||||
import AbstractApiClient from "../AbstractApiClient.js";
|
||||
import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord } from "@atproto/api";
|
||||
import { MSCache } from "../../Cache.js";
|
||||
import { BrainzMeta, PlayObject, SourcePlayerObj, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js";
|
||||
import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, MBID, NowPlayingUpdateThreshold, SourcePlayerObj, Second } from "../../../../core/Atomic.js";
|
||||
import { musicServiceToCononical } from '../listenbrainz/lzUtils.js';
|
||||
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
|
||||
import { RecordOptions } from "../../infrastructure/config/client/tealfm.js";
|
||||
@@ -15,6 +15,7 @@ import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js";
|
||||
import { ScrobbleSubmitError } from "../../errors/MSErrors.js";
|
||||
import { UpstreamError } from "../../errors/UpstreamError.js";
|
||||
import { decodeTid, generateTID } from '@ewanc26/tid';
|
||||
import { Duration } from "dayjs/plugin/duration.js";
|
||||
|
||||
export abstract class AbstractBlueSkyApiClient extends AbstractApiClient implements PagelessTimeRangeListens {
|
||||
|
||||
@@ -109,34 +110,44 @@ export const playToRecord = (play: PlayObject): ScrobbleRecord => {
|
||||
const record: ScrobbleRecord = {
|
||||
$type: "fm.teal.alpha.feed.play",
|
||||
trackName: play.data.track,
|
||||
artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: x.mbid })),
|
||||
artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })),
|
||||
duration: Math.round(play.data.duration),
|
||||
playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(),
|
||||
releaseName: play.data.album,
|
||||
submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`,
|
||||
musicServiceBaseDomain: musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService,
|
||||
isrc: play.data.isrc,
|
||||
recordingMbId: play.data.meta?.brainz?.recording,
|
||||
releaseMbId: play.data.meta?.brainz?.album
|
||||
trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID),
|
||||
recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID),
|
||||
releaseMbId: mbidUriOrUndefined(play.data.meta?.brainz?.album as MBID)
|
||||
};
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
type MBIDURI = `mbid:${MBID}`;
|
||||
|
||||
const mbidUriOrUndefined = (mbid?: MBID): undefined | MBIDURI => {
|
||||
if(mbid === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return mbidToUri(mbid);
|
||||
}
|
||||
export const mbidToUri = (mbid: MBID): MBIDURI => {
|
||||
return `mbid:${mbid}`;
|
||||
}
|
||||
|
||||
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): StatusRecord => {
|
||||
const { $type, ...item } = notPlaying
|
||||
? { trackName: "", artists: [] }
|
||||
: playToRecord(play);
|
||||
|
||||
// default "fallback" value
|
||||
let expiry: Dayjs = dayjs().add(10, 'minute');
|
||||
// 1min ago if paused -> try now + (duration - position) -> try now + duration -> fallback
|
||||
let expiry: Dayjs;
|
||||
if(notPlaying) {
|
||||
// if clearing status we set expiration as one minute in the past
|
||||
expiry = dayjs().subtract(1, 'minute');
|
||||
} else if(position !== undefined && play.data.duration !== undefined) {
|
||||
expiry = dayjs().add(play.data.duration - position, 'second');
|
||||
} else if(play.data.duration !== undefined) {
|
||||
expiry = dayjs().add(play.data.duration, 'second');
|
||||
} else {
|
||||
expiry = dayjs().add(nowPlayingExpirationDuration({play, position}));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -147,6 +158,26 @@ export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, positi
|
||||
};
|
||||
}
|
||||
|
||||
export const nowPlayingExpirationDuration = (data: Pick<SourcePlayerObj, 'play' | 'position'>): Duration => {
|
||||
let expiry: Dayjs = dayjs().add(10, 'minute');
|
||||
|
||||
const {
|
||||
position,
|
||||
play
|
||||
} = data;
|
||||
|
||||
// if we have position and duration then expiration is set as calculated end of listening session
|
||||
if(position !== undefined && play?.data.duration !== undefined) {
|
||||
expiry = dayjs().add(play.data.duration - position, 'second');
|
||||
} else if(play?.data.duration !== undefined) {
|
||||
// else if we have duration but not position then use track duration
|
||||
expiry = dayjs().add(play.data.duration, 'second');
|
||||
}
|
||||
|
||||
// otherwise use 10 minutes
|
||||
return dayjs.duration(expiry.diff(dayjs(), 'ms'));
|
||||
}
|
||||
|
||||
export const listRecordToPlay = (listRecord: ListRecord<ScrobbleRecord>): PlayObject => {
|
||||
const opts: RecordOptions = {};
|
||||
const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri);
|
||||
|
||||
+11
-4
@@ -9,11 +9,18 @@ import { artistCreditsToNames, artistCreditToName } from "../../../../core/Strin
|
||||
export const playToListenPayload = (play: PlayObject, version?: string): ListenPayload => {
|
||||
const {
|
||||
data: {
|
||||
playDate, artists = [],
|
||||
playDate,
|
||||
artists = [],
|
||||
// MB doesn't use this during submission AFAIK
|
||||
// instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release
|
||||
albumArtists = [], album, track, isrc, duration, meta: {
|
||||
brainz = {}, spotify = {},
|
||||
albumArtists = [],
|
||||
album,
|
||||
track,
|
||||
isrc,
|
||||
duration,
|
||||
meta: {
|
||||
brainz = {},
|
||||
spotify = {},
|
||||
} = {}
|
||||
}, meta: {
|
||||
mediaPlayerName, mediaPlayerVersion, musicService, source
|
||||
@@ -75,7 +82,7 @@ export const playToListenPayload = (play: PlayObject, version?: string): ListenP
|
||||
}
|
||||
|
||||
const minTrackData = removeUndefinedKeys<MinimumTrack>({
|
||||
artist_name: Array.from(new Set([...artists])).join(', '),
|
||||
artist_name: Array.from(new Set([...artists.map(artistCreditToName)])).join(', '),
|
||||
track_name: track,
|
||||
release_name: al,
|
||||
});
|
||||
|
||||
+2
-2
@@ -168,11 +168,11 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR
|
||||
this.logger.info('Auth test passed!');
|
||||
return true;
|
||||
} else {
|
||||
this.logger.error('Maloja API Response', {
|
||||
this.logger.error({
|
||||
status,
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
});
|
||||
},'Maloja API Response');
|
||||
throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new Error(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -121,7 +121,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
anyOk = true;
|
||||
this.logger.verbose(`Pushed notification to URLs`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to push notification for '${payload.title}' to URLs`, {cause: e});
|
||||
this.logger.warn(new Error(`Failed to push notification for '${payload.title}' to URLs`, {cause: e}));
|
||||
if(isDebugMode()) {
|
||||
this.logger.debug({ body }, 'Apprise Request')
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
FormatPlayObjectOptions,
|
||||
PaginatedTimeRangeOptions,
|
||||
REFRESH_STALE_DEFAULT,
|
||||
ReportedPlayerStatus,
|
||||
ScrobbledPlayObject,
|
||||
SourceIdentifier,
|
||||
TIME_WEIGHT,
|
||||
@@ -79,6 +80,7 @@ import { ComponentMigrationNew, PlaySelect, PlaySelectWithQueueStates, QueueStat
|
||||
import { asPlay } from "../../core/PlayMarshalUtils.js";
|
||||
import { DrizzleQueueRepository } from "../common/database/drizzle/repositories/QueueRepository.js";
|
||||
import { GenericRepository } from "../common/database/drizzle/repositories/BaseRepository.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
type PlatformMappedPlays = Map<string, {player: SourcePlayerObj, source: SourceIdentifier}>;
|
||||
type NowPlayingQueue = Map<string, PlatformMappedPlays>;
|
||||
@@ -120,6 +122,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
deadLetterQueued: number = 0;
|
||||
|
||||
supportsNowPlaying: boolean = false;
|
||||
nowPlayingIsRealtime: boolean = false;
|
||||
nowPlayingInit: boolean = false;
|
||||
nowPlayingEnabled: boolean;
|
||||
nowPlayingFilter: (queue: NowPlayingQueue) => SourcePlayerObj | undefined;
|
||||
@@ -1529,13 +1532,13 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
if(sourcePlayerData === undefined) {
|
||||
return;
|
||||
}
|
||||
let shouldUpdate: boolean,
|
||||
clientReason: string | undefined;
|
||||
const [npUpdateTop, npUpdateTopReason] = this.shouldUpdatePlayingNowResult(sourcePlayerData);
|
||||
shouldUpdate = npUpdateTop;
|
||||
if(!npUpdateTop) {
|
||||
this.npLogger.trace(`Not updating because ${npUpdateTopReason}`);
|
||||
} else {
|
||||
let [shouldUpdate, npUpdateTopReason] = this.shouldUpdatePlayingNow(sourcePlayerData);
|
||||
let clientReason: string | undefined;
|
||||
if(!shouldUpdate) {
|
||||
this.npLogger.trace(`Not updating, ${npUpdateTopReason}`);
|
||||
}
|
||||
|
||||
if(shouldUpdate) {
|
||||
const [clientUpdate, clientUpdateReason, level] = await this.shouldUpdatePlayingNowPlatformSpecific(sourcePlayerData);
|
||||
clientReason = clientUpdateReason;
|
||||
shouldUpdate = clientUpdate;
|
||||
@@ -1543,6 +1546,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
this.npLogger[level ?? 'trace'](`Not updating, ${npUpdateTopReason} --BUT-- ${clientUpdateReason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// finally, do the update
|
||||
if(shouldUpdate) {
|
||||
this.npLogger.verbose(`Updating because ${npUpdateTopReason}${clientReason !== undefined ? ` --AND-- ${clientReason}` : ''}`);
|
||||
try {
|
||||
@@ -1559,46 +1564,129 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
}
|
||||
}
|
||||
|
||||
shouldUpdatePlayingNowResult = (data: SourcePlayerObj): [boolean, string?] => {
|
||||
nowPlayingHasDiscrepancy = (data: SourcePlayerObj): [boolean, string?] => {
|
||||
if(this.nowPlayingLastPlay === undefined || this.nowPlayingLastUpdated === undefined) {
|
||||
return [true, 'Now Playing has not yet been set'];
|
||||
}
|
||||
|
||||
if(data.play.data.track === undefined) {
|
||||
return [false, 'play is missing track information'];
|
||||
}
|
||||
if((data.play.data.artists ?? []).length === 0) {
|
||||
return [false, 'play is missing artist information'];
|
||||
}
|
||||
|
||||
const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's'));
|
||||
|
||||
const playExistingDiscrepancy = (this.nowPlayingLastPlay.play !== undefined && data.play === undefined) || (this.nowPlayingLastPlay === undefined && data.play !== undefined);
|
||||
const bothPlaysExist = this.nowPlayingLastPlay.play !== undefined && data.play !== undefined;
|
||||
|
||||
const playerStatusChanged = this.nowPlayingLastPlay.status.calculated !== data.status.calculated;
|
||||
|
||||
// update if play *has* changed and time since last update is greater than min interval
|
||||
// this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently
|
||||
if(this.nowPlayingMinThreshold(data.play) < lastUpdateDiff && (playExistingDiscrepancy || playerStatusChanged || (bothPlaysExist && !playObjDataMatch(data.play, this.nowPlayingLastPlay.play)))) {
|
||||
return [true, `New Play differs from previous Now Playing and time since update ${lastUpdateDiff}s, greater than threshold ${this.nowPlayingMinThreshold(data.play)}`];
|
||||
}
|
||||
// update if play *has not* changed but last update is greater than max interval
|
||||
// this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long
|
||||
if(this.nowPlayingMaxThreshold(data.play) < lastUpdateDiff && (bothPlaysExist && playObjDataMatch(data.play, this.nowPlayingLastPlay.play))) {
|
||||
return [true, `Now Playing last updated ${lastUpdateDiff}s ago, greater than threshold ${this.nowPlayingMaxThreshold(data.play)}s`];
|
||||
if(playExistingDiscrepancy) {
|
||||
return [true, `previous update ${this.nowPlayingLastPlay.play !== undefined ? 'exists' : 'does not exist'} and current update ${data.play !== undefined ? 'exists' : 'does not exist'}`];
|
||||
}
|
||||
|
||||
return [false, `Now Playing ${bothPlaysExist && playObjDataMatch(data.play, this.nowPlayingLastPlay.play) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data.play)}s)`];
|
||||
if(this.nowPlayingLastPlay.play === undefined && data.play === undefined) {
|
||||
return [false, 'both previous and current update do not exist, nothing to update'];
|
||||
}
|
||||
|
||||
if(this.nowPlayingLastPlay.status.calculated !== data.status.calculated) {
|
||||
return [true, 'player state has changed'];
|
||||
}
|
||||
|
||||
if(!playObjDataMatch(data.play, this.nowPlayingLastPlay.play)) {
|
||||
return [true, 'previous update play data does not match current'];
|
||||
}
|
||||
|
||||
return [false, 'previous update data matches current'];
|
||||
}
|
||||
|
||||
shouldUpdatePlayingNow = (data: SourcePlayerObj): boolean => {
|
||||
return this.shouldUpdatePlayingNowResult(data)[0];
|
||||
protected nowPlayingThresholdsMet = (data: SourcePlayerObj) => {
|
||||
const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's'));
|
||||
const minMet = this.nowPlayingMinThreshold(data.play) < lastUpdateDiff;
|
||||
const minReason = `time since last update (${lastUpdateDiff}s) is ${minMet ? 'greater' : 'less'} than min threshold ${this.nowPlayingMinThreshold(data.play)}s`;
|
||||
|
||||
const maxMet = this.nowPlayingMaxThreshold(data.play) < lastUpdateDiff;
|
||||
const maxReason = `time since last update (${lastUpdateDiff}s) is ${maxMet ? 'greater' : 'less'} than max threshold ${this.nowPlayingMaxThreshold(data.play)}s`;
|
||||
|
||||
return {
|
||||
minMet,
|
||||
minReason,
|
||||
maxMet,
|
||||
maxReason
|
||||
}
|
||||
}
|
||||
|
||||
shouldUpdatePlayingNow = (sourcePlayerData: SourcePlayerObj): [boolean, string] => {
|
||||
let shouldUpdate: boolean;
|
||||
const thresholds = this.nowPlayingThresholdsMet(sourcePlayerData);
|
||||
// first we check if there is an obvious discrepancy between last updated and current update data
|
||||
// such as one missing, status change, no stored previous, etc...
|
||||
const [npUpdateTop, npUpdateTopReason] = this.nowPlayingHasDiscrepancy(sourcePlayerData);
|
||||
shouldUpdate = npUpdateTop;
|
||||
if(!npUpdateTop) {
|
||||
|
||||
if(npUpdateTopReason === 'previous update data matches current') {
|
||||
if(thresholds.maxMet) {
|
||||
return [true, `previous matches current update --AND-- ${thresholds.maxReason}`];
|
||||
} else {
|
||||
return [false, `previous matches current update --BUT-- ${thresholds.maxReason}`];
|
||||
}
|
||||
}
|
||||
|
||||
return [false, npUpdateTopReason];
|
||||
}
|
||||
|
||||
let validStatusReason: string;
|
||||
if(shouldUpdate) {
|
||||
// next we check if new player state is even valid to use for an update
|
||||
const [statusValid, reason] = this.nowPlayingIsRealtime ? playerInValidNPUpdateState(sourcePlayerData) : playerInNPPlayingOnlyState(sourcePlayerData);
|
||||
validStatusReason = reason;
|
||||
shouldUpdate = statusValid;
|
||||
if(!statusValid) {
|
||||
return [false, `${npUpdateTopReason} --BUT-- ${validStatusReason}`];
|
||||
}
|
||||
}
|
||||
|
||||
if(shouldUpdate && this.nowPlayingLastPlay !== undefined) {
|
||||
// at this point its possible we could update but we should respect minimum update intervals
|
||||
// and triggering this early means less, deeper checks
|
||||
const thresholds = this.nowPlayingThresholdsMet(sourcePlayerData);
|
||||
if (!thresholds.minMet) {
|
||||
shouldUpdate = false;
|
||||
return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- ${thresholds.minReason}`];
|
||||
}
|
||||
else if (
|
||||
// status hasn't changed
|
||||
this.nowPlayingLastPlay.status?.calculated === sourcePlayerData.status?.calculated
|
||||
// and both plays are defined and have not changed
|
||||
&& (this.nowPlayingLastPlay.play !== undefined && sourcePlayerData.play !== undefined)
|
||||
&& playObjDataMatch(sourcePlayerData.play, this.nowPlayingLastPlay.play)) {
|
||||
|
||||
// only update if we are passed max threshold
|
||||
shouldUpdate = thresholds.maxMet;
|
||||
if(!thresholds.maxMet) {
|
||||
return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- ${thresholds.maxReason}`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(shouldUpdate) {
|
||||
// check for valid play data if the update should be for a playing track
|
||||
if(playerInNPPlayingOnlyState(sourcePlayerData)) {
|
||||
if(sourcePlayerData.play?.data?.track === undefined) {
|
||||
shouldUpdate = false;
|
||||
return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- play is missing track information`];
|
||||
}
|
||||
if((sourcePlayerData.play?.data?.artists ?? []).length === 0) {
|
||||
shouldUpdate = false;
|
||||
return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- play is missing artist information`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(shouldUpdate && this.nowPlayingIsRealtime) {
|
||||
// prevent multiple clearing updates
|
||||
if(this.nowPlayingLastPlay !== undefined && shouldClearNPStatus(sourcePlayerData) && shouldClearNPStatus(this.nowPlayingLastPlay)) {
|
||||
shouldUpdate = false;
|
||||
return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- last update already cleared now playing`];
|
||||
}
|
||||
}
|
||||
|
||||
return [true, `${npUpdateTopReason} and ${validStatusReason}`];
|
||||
}
|
||||
|
||||
/** Implement this for specific requirements for updating playing now based on the scrobbler platform */
|
||||
protected shouldUpdatePlayingNowPlatformSpecific(data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> {
|
||||
return shouldUpdatePlayingNowPlatformWhenPlayingOnly(data);
|
||||
protected async shouldUpdatePlayingNowPlatformSpecific(data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> {
|
||||
return [true];
|
||||
}
|
||||
|
||||
protected doPlayingNow = (data: SourcePlayerObj): Promise<any> => Promise.resolve(undefined)
|
||||
@@ -1636,9 +1724,46 @@ export const nowPlayingUpdateByPlayDuration: NowPlayingUpdateThreshold = (play?:
|
||||
return (play?.data?.duration ?? 30) + 1;
|
||||
}
|
||||
|
||||
export const shouldUpdatePlayingNowPlatformWhenPlayingOnly = async (data: SourcePlayerObj): Promise<[boolean, string]> => {
|
||||
if(data.status.calculated === CALCULATED_PLAYER_STATUSES.playing || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)) {
|
||||
return [true, `calculated player status is ${data.status.calculated}`];
|
||||
export const shouldClearNPStatus = (data: SourcePlayerObj) => {
|
||||
return [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus);
|
||||
}
|
||||
|
||||
export const playerInNPPlayingOnlyState = (data: SourcePlayerObj): [boolean, string] => {
|
||||
// for lower-interval update clients (like listenbrainz, lastfm) IE not real-time
|
||||
// we don't want to create updates for paused/stopped because the NP data for these services
|
||||
// is only supposed to be updated intermittently
|
||||
//
|
||||
// so only allow an update if the player is actually playing
|
||||
if(!data.nowPlayingMode) {
|
||||
if(data.status.calculated === CALCULATED_PLAYER_STATUSES.playing) {
|
||||
return [true, `calculated player status is ${data.status.calculated}`];
|
||||
}
|
||||
return [false, `calculated player status is ${data.status.calculated} but must be playing`];
|
||||
}
|
||||
return [false, `calculated player status is ${data.status.calculated} but must be played/stopped`];
|
||||
return npPlayerInValidNPUpdateState(data);
|
||||
}
|
||||
|
||||
export const playerInValidNPUpdateState = (data: SourcePlayerObj): [boolean, string] => {
|
||||
// if the source player is not a "Now Playing" type (lz, endpoint Source, etc...)
|
||||
// then we only want to allow an update if the player state is a known "good" type IE don't allow on unknown
|
||||
if(!data.nowPlayingMode) {
|
||||
if([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) {
|
||||
return [true, `player in valid update state: '${data.status.calculated }'`];
|
||||
}
|
||||
return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`];
|
||||
}
|
||||
|
||||
return npPlayerInValidNPUpdateState(data);
|
||||
}
|
||||
|
||||
export const npPlayerInValidNPUpdateState = (data: SourcePlayerObj): [boolean, string] => {
|
||||
assert(data.nowPlayingMode === true, 'data is not in nowPlayingMode');
|
||||
|
||||
// if the source player *is* a "Now Playing" type
|
||||
// then we allow update on anything that isn't explicitly stopped
|
||||
// since these sources have limited reporting capability for calculating a valid state
|
||||
if(CALCULATED_PLAYER_STATUSES.stopped !== data.status.calculated as ReportedPlayerStatus) {
|
||||
return [true, `NP player in valid update state: '${data.status.calculated }'`];
|
||||
}
|
||||
return [false, `NP player is is invalid update state: stopped`];
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { PlayMatchResult, PlayObject, SourcePlayerObj } from "../../core/Atomic.
|
||||
import { CALCULATED_PLAYER_STATUSES, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus, SINGLE_USER_PLATFORM_ID_STR, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
|
||||
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js";
|
||||
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.js";
|
||||
import { DiscordClientConfig, DiscordStrongData } from "../common/infrastructure/config/client/discord.js";
|
||||
import { DiscordWSClient } from "../common/vendor/discord/DiscordWSClient.js";
|
||||
import { configToStrong } from "../common/vendor/discord/DiscordUtils.js";
|
||||
@@ -18,6 +18,7 @@ export default class DiscordScrobbler extends AbstractScrobbleClient {
|
||||
api: DiscordWSClient | DiscordIPCClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
override nowPlayingIsRealtime: boolean = true;
|
||||
apiMode!: 'ws' | 'ipc';
|
||||
|
||||
declare config: DiscordClientConfig & {data: DiscordStrongData };
|
||||
@@ -146,7 +147,7 @@ export default class DiscordScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
doPlayingNow = async (data: SourcePlayerObj) => {
|
||||
try {
|
||||
if([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus)) {
|
||||
if(shouldClearNPStatus(data)) {
|
||||
await this.api.sendActivity(undefined);
|
||||
} else {
|
||||
await this.api.sendActivity(data);
|
||||
@@ -157,33 +158,19 @@ export default class DiscordScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
|
||||
shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> => {
|
||||
if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)
|
||||
|| (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)
|
||||
|| data.status.stale) {
|
||||
const [sendOk, reasons, level = 'warn'] = await this.api.checkOkToSend();
|
||||
if (!sendOk) {
|
||||
return [false, `Cannot update playing now because api client is ${reasons}`, level as LogLevel];
|
||||
}
|
||||
|
||||
const [sendOk, reasons, level = 'warn'] = await this.api.checkOkToSend();
|
||||
if (!sendOk) {
|
||||
return [false, `Cannot update playing now because api client is ${reasons}`, level as LogLevel];
|
||||
}
|
||||
|
||||
if(this.api instanceof DiscordWSClient) {
|
||||
const [allowed, reason] = this.api.presenceIsAllowed();
|
||||
if(!allowed) {
|
||||
this.npLogger.debug(reason);
|
||||
return [false, reason];
|
||||
}
|
||||
}
|
||||
|
||||
return [true];
|
||||
} else {
|
||||
if(!data.nowPlayingMode && ![CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) {
|
||||
return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`];
|
||||
} else if(data.nowPlayingMode && CALCULATED_PLAYER_STATUSES.stopped) {
|
||||
this.npLogger.trace(`Will not update because now playing player is stopped => Found ${data.status.calculated}`);
|
||||
return [false,`playing player is stopped => Found ${data.status.calculated}` ]
|
||||
} else {
|
||||
return [false, 'player is in an unexpected state for discord usage']
|
||||
if(this.api instanceof DiscordWSClient) {
|
||||
const [allowed, reason] = this.api.presenceIsAllowed();
|
||||
if(!allowed) {
|
||||
this.npLogger.debug(reason);
|
||||
return [false, reason];
|
||||
}
|
||||
}
|
||||
|
||||
return [true];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FormatPlayObjectOptions, InternalConfigOptional, TimeRangeListensFetche
|
||||
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
|
||||
import LastfmApiClient, { LastFMIgnoredScrobble, playToClientPayload, formatPlayObj } from "../common/vendor/LastfmApiClient.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js";
|
||||
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, playerInNPPlayingOnlyState } from "./AbstractScrobbleClient.js";
|
||||
import { findCauseByReference } from "../utils/ErrorUtils.js";
|
||||
import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js';
|
||||
import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js';
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
|
||||
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js";
|
||||
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, playerInNPPlayingOnlyState } from "./AbstractScrobbleClient.js";
|
||||
import { isDebugMode } from "../utils.js";
|
||||
import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js";
|
||||
|
||||
|
||||
@@ -7,16 +7,20 @@ import { FormatPlayObjectOptions, CALCULATED_PLAYER_STATUSES, ReportedPlayerStat
|
||||
import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js';
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.js";
|
||||
import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js";
|
||||
import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js";
|
||||
import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js";
|
||||
import { AbstractBlueSkyApiClient, listRecordToPlay, playToRecord, playToStatusRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js";
|
||||
import { AbstractBlueSkyApiClient, listRecordToPlay, nowPlayingExpirationDuration, playToRecord, playToStatusRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { durationToHuman } from "../utils.js";
|
||||
|
||||
export default class TealScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
override nowPlayingIsRealtime: boolean = true;
|
||||
protected lastExpirationDate: Dayjs;
|
||||
|
||||
declare config: TealClientConfig;
|
||||
|
||||
@@ -35,6 +39,8 @@ export default class TealScrobbler extends AbstractScrobbleClient {
|
||||
} else {
|
||||
throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`);
|
||||
}
|
||||
this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration;
|
||||
this.nowPlayingMinThreshold = (_) => 20;
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => recordToPlay(obj);
|
||||
@@ -123,36 +129,39 @@ export default class TealScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
|
||||
doPlayingNow = async (data: SourcePlayerObj) => {
|
||||
const notPlaying = [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus);
|
||||
|
||||
const isClearing = shouldClearNPStatus(data);
|
||||
|
||||
// we can avoid additional calls to PDS for clearing a status if the status is about to expire, or is already expired.
|
||||
// this will usually happen if a player stops playing the last track in a queue
|
||||
// -- worth doing since PDS calls have a daily rate limit
|
||||
if(isClearing && (this.statusExpiresSoon() || this.statusAlreadyExpired())) {
|
||||
this.npLogger.debug(`Not calling status record update because status is about to expire (or has already), expiring ${durationToHuman(dayjs.duration(dayjs().diff(this.lastExpirationDate)))}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.updateStatusRecord(playToStatusRecord(data.play, notPlaying, data.position));
|
||||
await this.client.updateStatusRecord(playToStatusRecord(data.play, isClearing, data.position));
|
||||
if(!isClearing) {
|
||||
this.lastExpirationDate = dayjs().add(nowPlayingExpirationDuration(data));
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
wasLastStatusCleared = () => {
|
||||
return this.nowPlayingLastPlay !== undefined
|
||||
&& [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(this.nowPlayingLastPlay.status.calculated as ReportedPlayerStatus)
|
||||
}
|
||||
|
||||
shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> => {
|
||||
if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus) && !this.wasLastStatusCleared()
|
||||
|| [CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)
|
||||
|| (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)) {
|
||||
return [true];
|
||||
} else {
|
||||
if(!data.nowPlayingMode && ![CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) {
|
||||
return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`];
|
||||
} else if (this.wasLastStatusCleared()) {
|
||||
return [false, 'teal.fm status has already been set to expired'];
|
||||
} else if (data.nowPlayingMode && CALCULATED_PLAYER_STATUSES.stopped) {
|
||||
this.npLogger.trace(`Will not update because now playing player is stopped => Found ${data.status.calculated}`);
|
||||
return [false,`playing player is stopped => Found ${data.status.calculated}` ]
|
||||
} else {
|
||||
return [false, 'player is in an unexpected state for teal.fm usage']
|
||||
}
|
||||
protected statusExpiresSoon = () => {
|
||||
if(this.lastExpirationDate === undefined) {
|
||||
return false;
|
||||
}
|
||||
// may want to make this configurable in the future?
|
||||
return Math.abs(dayjs().diff(this.lastExpirationDate, 's')) < 15;
|
||||
}
|
||||
protected statusAlreadyExpired = () => {
|
||||
if(this.lastExpirationDate === undefined) {
|
||||
return false;
|
||||
}
|
||||
return dayjs().isAfter(this.lastExpirationDate);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr
|
||||
// TODO right now all sources requiring source interaction are covered by logic branches (deezer above and spotify here)
|
||||
// but eventually should update all source callbacks to url specific URLS to avoid ambiguity...
|
||||
// wish we could use state param to identify name/source but not all auth strategies and auth provides may provide access to that
|
||||
logger.info('Received auth code callback from Spotify', {label: 'Spotify'});
|
||||
logger.info({label: 'Spotify'}, 'Received auth code callback from Spotify');
|
||||
const source = scrobbleSources.getByNameAndType(state as string, 'spotify', true) as SpotifySource;
|
||||
const tokenResult = await source.handleAuthCodeCallback(req.query);
|
||||
let responseContent = 'OK';
|
||||
|
||||
@@ -78,16 +78,16 @@ export class AzuracastSource extends MemorySource {
|
||||
});
|
||||
const wsLogger = childLogger(this.logger, 'WS');
|
||||
this.client.addEventListener('retry', (e) => {
|
||||
wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`, { labels: 'WS' });
|
||||
wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`);
|
||||
});
|
||||
this.client.addEventListener('close', (e) => {
|
||||
wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`, { labels: 'WS' });
|
||||
wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`);
|
||||
if (e.reason.includes('unauthenticated')) {
|
||||
this.authed = false;
|
||||
}
|
||||
});
|
||||
this.client.addEventListener('open', (e) => {
|
||||
wsLogger.verbose(`Connection was established.`, { labels: 'WS' });
|
||||
wsLogger.verbose(`Connection was established.`);
|
||||
// if (this.authed) {
|
||||
// // was a reconnect, try auto authenticating
|
||||
// wsLogger.verbose('Resending auth message after (probably) reconnection...');
|
||||
|
||||
@@ -202,7 +202,7 @@ export default class DeezerSource extends AbstractSource {
|
||||
} = e;
|
||||
const msg = response !== undefined ? `API Call failed: Server Response => ${ssMessage}` : `API Call failed: ${message}`;
|
||||
const responseMeta = ssResp ?? text;
|
||||
this.logger.error(msg, {status, response: responseMeta});
|
||||
this.logger.error({status, response: responseMeta}, msg);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ from "@jellyfin/sdk/lib/index.js";
|
||||
import dayjs from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import { ArtistCredit, BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js";
|
||||
import { artistNamesToCredits, buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import { artistNamesToCredits, artistNameToCredit, buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
InternalConfig,
|
||||
@@ -59,6 +59,7 @@ import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js";
|
||||
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
|
||||
import { FixedSizeList } from "fixed-size-list";
|
||||
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js";
|
||||
import { noCasePropObj } from "../utils/DataUtils.js";
|
||||
|
||||
const shortDeviceId = truncateStringToLength(10, '');
|
||||
|
||||
@@ -192,13 +193,13 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
}
|
||||
});
|
||||
this.wsClient.addEventListener('close', (e) => {
|
||||
this.logger.warn(`Connection was closed: ${e.code} => ${e.reason}`, {labels: 'WS'});
|
||||
this.logger.warn({labels: 'WS'}, `Connection was closed: ${e.code} => ${e.reason}`);
|
||||
});
|
||||
this.wsClient.addEventListener('open', (e) => {
|
||||
this.logger.verbose(`Connection was established.`, {labels: 'WS'});
|
||||
this.logger.verbose({labels: 'WS'}, `Connection was established.`);
|
||||
});
|
||||
this.wsClient.addEventListener('message', (e) => {
|
||||
this.logger.debug(e.data, {labels: 'WS'});
|
||||
this.logger.debug({labels: 'WS'}, e.data);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -466,17 +467,19 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
meta.albumArtist = [ProviderIds.MusicBrainzAlbumArtist];
|
||||
}
|
||||
|
||||
const normalizedArtists = Artists.map(x => typeof x === 'string' ? artistNameToCredit(x) : artistNameToCredit(noCasePropObj(x)));
|
||||
let playArtists: ArtistCredit[] = [];
|
||||
if(Artists.length === 1 && meta.artist !== undefined) {
|
||||
playArtists.push({name: Artists[0], mbid: meta.artist[0]});
|
||||
if(normalizedArtists.length === 1 && meta.artist !== undefined) {
|
||||
playArtists.push({...normalizedArtists[0], mbid: meta.artist[0]});
|
||||
} else {
|
||||
playArtists = artistNamesToCredits(Artists);
|
||||
playArtists = normalizedArtists;
|
||||
}
|
||||
const normalizedAlbumArtists = AlbumArtists.map(x => typeof x === 'string' ? artistNameToCredit(x) : artistNameToCredit(noCasePropObj(x)));
|
||||
let playAlbumArtists: ArtistCredit[] = [];
|
||||
if(AlbumArtists.length === 1 && meta.albumArtist.length === 1) {
|
||||
playAlbumArtists.push({name: AlbumArtists[0], mbid: meta.albumArtist[0]});
|
||||
if(normalizedAlbumArtists.length === 1 && meta.albumArtist !== undefined) {
|
||||
playAlbumArtists.push({...normalizedAlbumArtists[0], mbid: meta.albumArtist[0]});
|
||||
} else {
|
||||
playAlbumArtists = artistNamesToCredits(AlbumArtists);
|
||||
playAlbumArtists = normalizedAlbumArtists;
|
||||
}
|
||||
|
||||
const play: PlayObjectLifecycleless = {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
|
||||
import { normalizeWSAddress } from "../utils/NetworkUtils.js";
|
||||
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js";
|
||||
import { artistNamesToCredits } from "../../core/StringUtils.js";
|
||||
|
||||
const CLIENT_STATE = {
|
||||
0: 'connecting',
|
||||
@@ -83,16 +84,16 @@ export class MusikcubeSource extends MemoryPositionalSource {
|
||||
});
|
||||
const wsLogger = childLogger(this.logger, 'WS');
|
||||
this.client.addEventListener('retry', (e) => {
|
||||
wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`, {labels: 'WS'});
|
||||
wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`);
|
||||
});
|
||||
this.client.addEventListener('close', (e) => {
|
||||
wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`, {labels: 'WS'});
|
||||
wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`);
|
||||
if (e.reason.includes('unauthenticated')) {
|
||||
this.authed = false;
|
||||
}
|
||||
});
|
||||
this.client.addEventListener('open', (e) => {
|
||||
wsLogger.verbose(`Connection was established.`, {labels: 'WS'});
|
||||
wsLogger.verbose(`Connection was established.`);
|
||||
if (this.authed) {
|
||||
// was a reconnect, try auto authenticating
|
||||
wsLogger.verbose('Resending auth message after (probably) reconnection...');
|
||||
@@ -181,8 +182,8 @@ export class MusikcubeSource extends MemoryPositionalSource {
|
||||
}
|
||||
},
|
||||
} = obj;
|
||||
const artists = [];
|
||||
const albumArtists = [];
|
||||
const artists: string[] = [];
|
||||
const albumArtists: string[] = [];
|
||||
if(artist !== undefined) {
|
||||
artists.push(artist);
|
||||
}
|
||||
@@ -191,8 +192,8 @@ export class MusikcubeSource extends MemoryPositionalSource {
|
||||
}
|
||||
const play: PlayObjectLifecycleless = {
|
||||
data: {
|
||||
artists: artists,
|
||||
albumArtists,
|
||||
artists: artistNamesToCredits(artists),
|
||||
albumArtists: artistNamesToCredits(albumArtists),
|
||||
album: album === '' ? undefined : album,
|
||||
track: title === '' ? undefined: title,
|
||||
duration: playing_duration
|
||||
|
||||
@@ -302,7 +302,10 @@ export abstract class AbstractPlayerState {
|
||||
playDateCompleted: completed ? dayjs() : undefined,
|
||||
repeat: this.isRepeatPlay
|
||||
},
|
||||
meta: this.currentPlay.meta
|
||||
meta: {
|
||||
...this.currentPlay.meta,
|
||||
trackProgressPosition: this.getPosition() ?? this.currentPlay.meta.trackProgressPosition
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -438,8 +438,8 @@ export default class ScrobbleSources {
|
||||
} break;
|
||||
case 'maloja': {
|
||||
const data = removeUndefinedKeys<MalojaData>({
|
||||
url: process.env.MALOJA_URL,
|
||||
apiKey: process.env.MALOJA_API_KEY
|
||||
url: process.env.SOURCE_MALOJA_URL,
|
||||
apiKey: process.env.SOURCE_MALOJA_API_KEY
|
||||
}, false);
|
||||
const p = getCommonComponentEnvConfig('SOURCE_MALOJA');
|
||||
if (nonEmptyObj(data) || nonEmptyObj(p)) {
|
||||
|
||||
@@ -267,7 +267,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag
|
||||
try {
|
||||
spotifyCreds = await readJson(this.workingCredsPath, {throwOnNotFound: false, interpolateEnvs: false}) as any;
|
||||
} catch (e) {
|
||||
this.logger.warn('Current spotify credentials file exists but could not be parsed', { path: this.workingCredsPath });
|
||||
this.logger.warn({ path: this.workingCredsPath }, 'Current spotify credentials file exists but could not be parsed');
|
||||
}
|
||||
|
||||
const {token: accessToken = undefined, refreshToken = undefined} = (spotifyCreds || {}) as any;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js";
|
||||
import { SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js";
|
||||
import { getSubsonicResponse, SubsonicResponse, SubsonicResponseCommon } from "../common/vendor/subsonic/interfaces.js";
|
||||
import { parseRetryAfterSecsFromObj, removeDuplicates, sleep } from "../utils.js";
|
||||
import { removeDuplicates } from "../utils.js";
|
||||
import { findCauseByFunc } from "../utils/ErrorUtils.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
@@ -18,6 +18,7 @@ import { PlayerStateOptions } from './PlayerState/AbstractPlayerState.js';
|
||||
import { Logger } from '@foxxmd/logging';
|
||||
import { baseFormatPlayObj } from '../utils/PlayTransformUtils.js';
|
||||
import { noRetryOnUpstreamError, tryApiCall } from '../utils/RequestUtils.js';
|
||||
import { artistNameToCredit } from '../../core/StringUtils.js';
|
||||
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
@@ -78,7 +79,7 @@ export class SubsonicSource extends MemorySource {
|
||||
|
||||
const play: PlayObjectLifecycleless = {
|
||||
data: {
|
||||
artists: [artist],
|
||||
artists: [artistNameToCredit(artist)],
|
||||
album,
|
||||
track: title,
|
||||
duration,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
|
||||
import { isDebugMode } from "../utils.js";
|
||||
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js";
|
||||
import { artistNamesToCredits } from "../../core/StringUtils.js";
|
||||
|
||||
const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
|
||||
'playing': REPORTED_PLAYER_STATUSES.playing,
|
||||
@@ -142,8 +143,8 @@ export class VLCSource extends MemoryPositionalSource {
|
||||
artist
|
||||
} = obj;
|
||||
|
||||
let artists = [];
|
||||
let albumArtists = [];
|
||||
let artists: string[] = [];
|
||||
let albumArtists: string[] = [];
|
||||
const validArtist = firstNonEmptyStr([artist, StreamArtist, ALBUMARTIST, Writer]);
|
||||
if(artist !== undefined) {
|
||||
artists.push(validArtist);
|
||||
@@ -217,8 +218,8 @@ export class VLCSource extends MemoryPositionalSource {
|
||||
|
||||
const play: PlayObjectLifecycleless = {
|
||||
data: {
|
||||
artists: artists,
|
||||
albumArtists,
|
||||
artists: artistNamesToCredits(artists),
|
||||
albumArtists: artistNamesToCredits(albumArtists),
|
||||
album,
|
||||
track: trackName,
|
||||
duration: length
|
||||
|
||||
@@ -8,10 +8,12 @@ import JellyfinApiSource from "../../sources/JellyfinApiSource.js";
|
||||
import validSession from './validSession.json' with { type: "json" };
|
||||
import { JellyApiData } from "../../common/infrastructure/config/source/jellyfin.js";
|
||||
import { generatePlay } from "../../../core/PlayTestUtils.js";
|
||||
import { fakerJA } from "@faker-js/faker";
|
||||
import { faker, fakerJA } from "@faker-js/faker";
|
||||
import {
|
||||
// @ts-expect-error weird typings?
|
||||
SessionInfo,
|
||||
// @ts-expect-error weird typings?
|
||||
BaseItemDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client/index.js";
|
||||
// @ts-expect-error weird typings?
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/index.js";
|
||||
@@ -372,4 +374,88 @@ describe("Jellyfin API Source", function() {
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('Play Data Formatting', function() {
|
||||
|
||||
it('Should handle artists as strings or objects', async function () {
|
||||
const itemStr: BaseItemDto = {
|
||||
Name: faker.word.words({count: {min: 1, max: 3}}),
|
||||
Artists: [faker.word.words({count: {min: 1, max: 3}})]
|
||||
};
|
||||
|
||||
const playStr = JellyfinApiSource.formatPlayObj(itemStr);
|
||||
|
||||
expect(playStr.data.artists).length(1);
|
||||
expect(playStr.data.artists[0].name).eq(itemStr.Artists[0]);
|
||||
|
||||
const itemObj: BaseItemDto = {
|
||||
Name: faker.word.words({count: {min: 1, max: 3}}),
|
||||
Artists: [{Name: faker.word.words({count: {min: 1, max: 3}})}]
|
||||
};
|
||||
|
||||
const playObj = JellyfinApiSource.formatPlayObj(itemObj);
|
||||
|
||||
expect(playObj.data.artists).length(1);
|
||||
expect(playObj.data.artists[0].name).eq(itemObj.Artists[0].Name);
|
||||
});
|
||||
|
||||
it('Should handle album artists as strings or objects', async function () {
|
||||
const itemStr: BaseItemDto = {
|
||||
Name: faker.word.words({count: {min: 1, max: 3}}),
|
||||
Artists: [faker.word.words({count: {min: 1, max: 3}})],
|
||||
AlbumArtists: [faker.word.words({count: {min: 1, max: 3}})]
|
||||
};
|
||||
|
||||
const playStr = JellyfinApiSource.formatPlayObj(itemStr);
|
||||
|
||||
expect(playStr.data.albumArtists).length(1);
|
||||
expect(playStr.data.albumArtists[0].name).eq(itemStr.AlbumArtists[0]);
|
||||
|
||||
const itemObj: BaseItemDto = {
|
||||
Name: faker.word.words({count: {min: 1, max: 3}}),
|
||||
Artists: [{Name: faker.word.words({count: {min: 1, max: 3}})}],
|
||||
AlbumArtists: [{Name: faker.word.words({count: {min: 1, max: 3}})}]
|
||||
};
|
||||
|
||||
const playObj = JellyfinApiSource.formatPlayObj(itemObj);
|
||||
|
||||
expect(playObj.data.albumArtists).length(1);
|
||||
expect(playObj.data.albumArtists[0].name).eq(itemObj.AlbumArtists[0].Name);
|
||||
});
|
||||
|
||||
it('Should add mbids to artist props', async function () {
|
||||
const itemStr: BaseItemDto = {
|
||||
Name: faker.word.words({count: {min: 1, max: 3}}),
|
||||
Artists: [faker.word.words({count: {min: 1, max: 3}})],
|
||||
AlbumArtists: [faker.word.words({count: {min: 1, max: 3}})],
|
||||
ProviderIds: {
|
||||
MusicBrainzArtist: 'foo',
|
||||
MusicBrainzAlbumArtist: 'bar'
|
||||
}
|
||||
};
|
||||
|
||||
const playStr = JellyfinApiSource.formatPlayObj(itemStr);
|
||||
|
||||
expect(playStr.data.artists[0].mbid).eq(itemStr.ProviderIds.MusicBrainzArtist);
|
||||
expect(playStr.data.albumArtists[0].mbid).eq(itemStr.ProviderIds.MusicBrainzAlbumArtist);
|
||||
});
|
||||
|
||||
it('Does not add mbids to artist props if there are multiple artists', async function () {
|
||||
const itemStr: BaseItemDto = {
|
||||
Name: faker.word.words({count: {min: 1, max: 3}}),
|
||||
Artists: [faker.word.words({count: {min: 1, max: 3}}), faker.word.words({count: {min: 1, max: 3}})],
|
||||
AlbumArtists: [faker.word.words({count: {min: 1, max: 3}}), faker.word.words({count: {min: 1, max: 3}})],
|
||||
ProviderIds: {
|
||||
MusicBrainzArtist: 'foo',
|
||||
MusicBrainzAlbumArtist: 'bar'
|
||||
}
|
||||
};
|
||||
|
||||
const playStr = JellyfinApiSource.formatPlayObj(itemStr);
|
||||
|
||||
expect(playStr.data.artists[0].mbid).is.undefined;
|
||||
expect(playStr.data.albumArtists[0].mbid).is.undefined;
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,7 +457,7 @@ describe('#MB Missing Types', function() {
|
||||
|
||||
it('Finds none missing when all mbids are defined', function() {
|
||||
|
||||
const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'track']});
|
||||
const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'recording']});
|
||||
const missing = missingMbidTypes(play);
|
||||
expect(missing.length).eq(0);
|
||||
});
|
||||
@@ -471,7 +471,7 @@ describe('#MB Missing Types', function() {
|
||||
|
||||
it('Finds duration missing', function() {
|
||||
|
||||
const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'track']});
|
||||
const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'recording']});
|
||||
delete play.data.duration;
|
||||
const missing = missingMbidTypes(play);
|
||||
expect(missing.length).eq(1);
|
||||
@@ -488,7 +488,7 @@ describe('#MB Missing Types', function() {
|
||||
|
||||
it('intersect is not empty when missing any desired types', function() {
|
||||
|
||||
const play = withBrainz(generatePlay(), {include: ['album', 'track']});
|
||||
const play = withBrainz(generatePlay(), {include: ['album', 'recording']});
|
||||
const missing = missingMbidTypes(play);
|
||||
expect(missing).to.have.members(['artists']);
|
||||
expect(intersect(DEFAULT_MISSING_TYPES, missing)).length.is.greaterThan(0);
|
||||
|
||||
@@ -970,7 +970,7 @@ describe('Now Playing', function() {
|
||||
await using npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}));
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0];
|
||||
expect(res).to.be.true;
|
||||
});
|
||||
|
||||
@@ -983,7 +983,7 @@ describe('Now Playing', function() {
|
||||
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) + 1, 's');
|
||||
npScrobbler.nowPlayingLastPlay = lastUpdate;
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate)[0];
|
||||
expect(res).to.be.true;
|
||||
});
|
||||
|
||||
@@ -996,7 +996,7 @@ describe('Now Playing', function() {
|
||||
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) - 1, 's');
|
||||
npScrobbler.nowPlayingLastPlay = lastUpdate;
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate)[0];
|
||||
expect(res).to.be.false;
|
||||
});
|
||||
|
||||
@@ -1009,7 +1009,7 @@ describe('Now Playing', function() {
|
||||
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) + 1, 's');
|
||||
npScrobbler.nowPlayingLastPlay = lastUpdate;
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}));
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0];
|
||||
expect(res).to.be.true;
|
||||
});
|
||||
|
||||
@@ -1022,7 +1022,7 @@ describe('Now Playing', function() {
|
||||
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) - 1, 's');
|
||||
npScrobbler.nowPlayingLastPlay = lastUpdate;
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}));
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0];
|
||||
expect(res).to.be.false;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import chai, { expect } from 'chai';
|
||||
import asPromised from 'chai-as-promised';
|
||||
import { after, before, describe, it } from 'mocha';
|
||||
import { generateLastfmTrackObject, generateMbid, generatePlay, generateTealPlayRecord } from "../../../core/PlayTestUtils.js";
|
||||
import { AbstractBlueSkyApiClient, listRecordToPlay } from '../../common/vendor/bluesky/AbstractBlueSkyApiClient.js';
|
||||
import { generateArtistCredits, generatePlay, generateTealPlayRecord, withBrainz } from "../../../core/PlayTestUtils.js";
|
||||
import { listRecordToPlay, playToRecord } from '../../common/vendor/bluesky/AbstractBlueSkyApiClient.js';
|
||||
import dayjs from 'dayjs';
|
||||
import { artistCreditsToNames } from '../../../core/StringUtils.js';
|
||||
|
||||
@@ -55,4 +55,20 @@ describe('#tealfm Record to Play', function() {
|
||||
expect(play.data.meta?.brainz.artist).to.be.undefined;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('#tealfm Play To Record', function () {
|
||||
|
||||
it('Adds mbids with uri format', function () {
|
||||
|
||||
const play = withBrainz(generatePlay({artists: generateArtistCredits(2)}), {include: ['recording']});
|
||||
const record = playToRecord(play);
|
||||
|
||||
expect(record.recordingMbId).to.eq(`mbid:${play.data.meta.brainz.recording}`);
|
||||
expect(record.releaseMbId).is.undefined;
|
||||
expect(record.artists).length(2);
|
||||
expect(record.artists[0].artistName).eq(play.data.artists[0].name);
|
||||
expect(record.artists[0].artistMbId).eq(`mbid:${play.data.artists[0].mbid}`);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -133,6 +133,18 @@ it('Proxy object has case-insensitive keys', function() {
|
||||
expect(iObj.foo).eq('bar');
|
||||
});
|
||||
|
||||
it('Proxy object handles spread', function() {
|
||||
const myObj = {FOO: 'bar', WIDget: 'cool'};
|
||||
|
||||
const iObj = noCasePropObj(myObj);
|
||||
const { foo, ...rest } = iObj;
|
||||
|
||||
expect(foo).is.not.undefined;
|
||||
expect(foo).eq('bar');
|
||||
expect(rest.widget).is.not.undefined;
|
||||
expect(rest.widget).eq('cool');
|
||||
});
|
||||
|
||||
describe('Interpolation', function() {
|
||||
|
||||
it('interpolates values', function() {
|
||||
|
||||
@@ -15,6 +15,16 @@ export const asArray = <T>(data: T | T[]): T[] => {
|
||||
|
||||
const handler: ProxyHandler<object> =
|
||||
{
|
||||
ownKeys(target) {
|
||||
return Reflect.ownKeys(target).map(x => typeof x === 'string' ? x.toLocaleLowerCase() : x);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
return {
|
||||
...Reflect.getOwnPropertyDescriptor(target, key),
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
},
|
||||
get: function (target, key) {
|
||||
//console.log("key: " + key.toString());
|
||||
if (typeof key == "string") {
|
||||
@@ -65,30 +75,36 @@ const checkAtomic = (value) => {
|
||||
return value;
|
||||
}
|
||||
|
||||
export type toLowerCase<T> = {
|
||||
[K in keyof T]: T[K]
|
||||
}
|
||||
|
||||
export type LowercaseString<S extends string> = S extends `${infer Str}`
|
||||
? `${Lowercase<Str>}`
|
||||
: S;
|
||||
|
||||
/** https://medium.com/@vincent.dibon_78881/type-level-uppercase-and-lowercase-in-typescript-c18f574f4572 */
|
||||
export type LowercaseKeys<T> = {
|
||||
[K in keyof T as LowercaseString<Extract<K, string>>]: T[K]
|
||||
};
|
||||
|
||||
/** Return a Proxy of an object where keys can be accessed case-insensitive
|
||||
*
|
||||
* https://stackoverflow.com/a/50102779/1469797
|
||||
* Note: objects from rest operator in spread are *always* lowercase
|
||||
*
|
||||
* @see https://stackoverflow.com/a/50102779/1469797
|
||||
*/
|
||||
export const noCasePropObj = <T>(obj: T): T => {
|
||||
let newObj;
|
||||
export const noCasePropObj = <T extends object>(obj: T): LowercaseKeys<T> => {
|
||||
|
||||
if (typeof obj == "object") {
|
||||
newObj = new Proxy({}, handler);
|
||||
// traverse the Original object converting string keys to upper case
|
||||
for (var key in obj) {
|
||||
if (typeof key == "string") {
|
||||
var objKey = key.toUpperCase();
|
||||
if (!(key in newObj))
|
||||
newObj[objKey] = checkAtomic(obj[key]);
|
||||
}
|
||||
const newObj = new Proxy<LowercaseKeys<T>>({} as LowercaseKeys<T>, handler);
|
||||
// traverse the Original object converting string keys to upper case
|
||||
for (var key in obj) {
|
||||
if (typeof key == "string") {
|
||||
var objKey = key.toUpperCase();
|
||||
if (!(key in newObj))
|
||||
newObj[objKey] = checkAtomic(obj[key]);
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(obj)) {
|
||||
// in an array of objects convert to upper case string keys within each row
|
||||
newObj = new Array();
|
||||
for (var i = 0; i < obj.length; i++)
|
||||
newObj[i] = checkAtomic(obj[i]);
|
||||
}
|
||||
return newObj; // object with upper cased keys
|
||||
}
|
||||
export async function readJson(this: any, path: any, options: ReadJsonOptions = {}) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ActivityTimeline = (props: ActivityDetailProps) => {
|
||||
} = {}
|
||||
} = play;
|
||||
|
||||
let scrobbleSummary: JSX.Element,
|
||||
let scrobbleSummary: React.JSX.Element,
|
||||
scrobbleIconProps: Record<string, any> = {
|
||||
color: 'green.focusRing'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { ArtistCredit as AC } from '../../core/Atomic';
|
||||
|
||||
import { HStack, Tag } from "@chakra-ui/react"
|
||||
import { SiMusicbrainz } from "react-icons/si";
|
||||
import { Tooltip } from './ChakraTooltip';
|
||||
|
||||
export const ArtistCredit = (props: { data: AC, showIdLink?: boolean }) => {
|
||||
|
||||
const {
|
||||
data,
|
||||
showIdLink = true
|
||||
} = props;
|
||||
|
||||
if (!showIdLink || Object.keys(data).length === 1) {
|
||||
return data.name;
|
||||
}
|
||||
|
||||
return <Fragment>
|
||||
<HStack>
|
||||
{data.name}
|
||||
<HStack style={{ userSelect: 'none' }}>
|
||||
{data.mbid !== undefined ? <Tooltip content={`MBID ${data.mbid}`} interactive><a target='__blank' href={`https://musicbrainz.org/artist/${data.mbid}`}><SiMusicbrainz /></a></Tooltip> : null}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Fragment>
|
||||
|
||||
}
|
||||
|
||||
export const ArtistCreditTags = (props: { data: AC[], showIdLink?: boolean }) => {
|
||||
return <HStack>{props.data.map((x, index) => {
|
||||
return (
|
||||
<Tag.Root key={index}>
|
||||
<Tag.Label userSelect="all"><ArtistCredit data={x} showIdLink={props.showIdLink} /></Tag.Label>
|
||||
</Tag.Root>
|
||||
);
|
||||
})}</HStack>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface TooltipProps extends ChakraTooltip.RootProps {
|
||||
showArrow?: boolean
|
||||
portalled?: boolean
|
||||
portalRef?: React.RefObject<HTMLElement | null>
|
||||
content: React.ReactNode
|
||||
contentProps?: ChakraTooltip.ContentProps
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
|
||||
function Tooltip(props, ref) {
|
||||
const {
|
||||
showArrow,
|
||||
children,
|
||||
disabled,
|
||||
portalled = true,
|
||||
content,
|
||||
contentProps,
|
||||
portalRef,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
if (disabled) return children
|
||||
|
||||
return (
|
||||
<ChakraTooltip.Root {...rest}>
|
||||
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraTooltip.Positioner>
|
||||
<ChakraTooltip.Content ref={ref} {...contentProps}>
|
||||
{showArrow && (
|
||||
<ChakraTooltip.Arrow>
|
||||
<ChakraTooltip.ArrowTip />
|
||||
</ChakraTooltip.Arrow>
|
||||
)}
|
||||
{content}
|
||||
</ChakraTooltip.Content>
|
||||
</ChakraTooltip.Positioner>
|
||||
</Portal>
|
||||
</ChakraTooltip.Root>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -126,7 +126,7 @@ export const ChakraPlainBlock = (props: ChakraPlainBlockProps) => {
|
||||
return safeStringify(code);
|
||||
}, [code]);
|
||||
|
||||
let header: JSX.Element | null;
|
||||
let header: React.JSX.Element | null;
|
||||
if(props.title === undefined && maxLines === undefined) {
|
||||
header = null;
|
||||
} else {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
.jsondiffpatch-added .jsondiffpatch-property-name,
|
||||
.jsondiffpatch-added .jsondiffpatch-value pre,
|
||||
.jsondiffpatch-modified .jsondiffpatch-right-value pre,
|
||||
.jsondiffpatch-textdiff-added {
|
||||
background: #0e2e1b;
|
||||
}
|
||||
|
||||
.jsondiffpatch-deleted .jsondiffpatch-property-name,
|
||||
.jsondiffpatch-deleted pre,
|
||||
.jsondiffpatch-modified .jsondiffpatch-left-value pre,
|
||||
.jsondiffpatch-textdiff-deleted {
|
||||
background: #371516 ;
|
||||
}
|
||||
|
||||
/* .jsondiffpatch-delta, .jsondiffpatch-delta ul {
|
||||
padding: 0 0 0 0;
|
||||
} */
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { ComponentProps } from 'react';
|
||||
import JsonDiffReact from 'jsondiffpatch-react';
|
||||
import { MSErrorBoundary } from './ErrorBoundary';
|
||||
import { MarkOptional } from 'ts-essentials';
|
||||
import { patchObject } from '../../core/DataUtils';
|
||||
import './JsonDiff.css';
|
||||
import { Changeset } from 'json-diff-ts';
|
||||
|
||||
type DiffableVal = [] | object | string;
|
||||
|
||||
export interface JsonDiffReactProps {
|
||||
right: DiffableVal,
|
||||
left: DiffableVal
|
||||
show?: boolean,
|
||||
annotated?: boolean,
|
||||
tips?: any,
|
||||
objectHash?: CallableFunction,
|
||||
}
|
||||
|
||||
export type JsonDiffPatchProps = MarkOptional<JsonDiffReactProps, 'right'> & {
|
||||
diff?: Changeset
|
||||
}
|
||||
|
||||
export const JsonDiffPatch = (props: JsonDiffPatchProps) => {
|
||||
const {
|
||||
right,
|
||||
left,
|
||||
diff,
|
||||
...rest
|
||||
} = props;
|
||||
const detachedLeft = JSON.parse(JSON.stringify(left));
|
||||
let realRight: DiffableVal;
|
||||
if (right !== undefined) {
|
||||
realRight = structuredClone(right);
|
||||
} else if (diff !== undefined) {
|
||||
realRight = patchObject(left, diff);
|
||||
} else {
|
||||
throw new Error(`must provide either 'right' or 'diff'`);
|
||||
}
|
||||
return <MSErrorBoundary>
|
||||
<JsonDiffReact {...rest} left={detachedLeft} right={realRight} />
|
||||
</MSErrorBoundary>
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Collapsible, Stack, Box, useBreakpointValue } from "@chakra-ui/react"
|
||||
import { diff } from "jsondiffpatch";
|
||||
import { ComponentProps, PropsWithChildren, useState, useEffect, useMemo } from "react";
|
||||
import { LuChevronRight } from "react-icons/lu"
|
||||
|
||||
//padding="0" borderWidth="0px"
|
||||
|
||||
interface MSCollapsibleInternalProps {
|
||||
indicator?: string | JSX.Element
|
||||
indicator?: string | React.JSX.Element
|
||||
boxProps?: object
|
||||
triggerProps?: object
|
||||
indicatorProps?: object
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Fragment, useMemo, useState } from 'react';
|
||||
import { EmptyState, DataList, HStack, Tag, Tabs, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator, IconButton, Container, SimpleGrid, Float, Spacer, Icon, Link, Span, Show } from "@chakra-ui/react"
|
||||
import { EmptyState, DataList, HStack, Tag, Tabs, Wrap, Box, Flex, SegmentGroup, Stack, Text, Tooltip, Separator, IconButton, Container, SimpleGrid, Float, Spacer, Icon, Link, Span, Show } from "@chakra-ui/react"
|
||||
import { LuCode, LuText, LuCheck, LuX } from "react-icons/lu"
|
||||
import { JsonPlayObject, PlayObjectLifecycleless } from '../../core/Atomic.js';
|
||||
import { shortTodayAwareFormat, timeToHumanTimestamp } from '../../core/TimeUtils.js';
|
||||
@@ -8,6 +8,7 @@ import { ChakraCodeBlock } from './CodeBlock.js';
|
||||
import { TextMuted } from './TextMuted.js';
|
||||
import { formatNumber } from '../../core/DataUtils.js';
|
||||
import { Muted } from './Typography.js';
|
||||
import { ArtistCreditTags } from './ArtistCreditDisplay.js';
|
||||
|
||||
const EmptyPlayData = () => {
|
||||
return (
|
||||
@@ -49,7 +50,7 @@ export const PlayData = (props?: PlayInfoProps) => {
|
||||
|
||||
const [codeMode, setCodeMode] = useState(false);
|
||||
|
||||
let code: JSX.Element | null = null;
|
||||
let code: React.JSX.Element | null = null;
|
||||
|
||||
const comparable = showCompare && final !== undefined;
|
||||
|
||||
@@ -95,20 +96,14 @@ export const PlayDataDataList = (props: { play: JsonPlayObject, dates: DisplayDa
|
||||
} = props;
|
||||
|
||||
|
||||
let albumArtistElm: JSX.Element;
|
||||
let albumArtistElm: React.JSX.Element;
|
||||
|
||||
if (play.data.albumArtists !== undefined && play.data.albumArtists.length > 0) {
|
||||
albumArtistElm = (
|
||||
<DataList.Item flexGrow="1">
|
||||
<DataList.ItemLabel>Album Artists</DataList.ItemLabel>
|
||||
<DataList.ItemValue>
|
||||
<HStack>{play.data.albumArtists.map((x, index) => {
|
||||
return (
|
||||
<Tag.Root key={index}>
|
||||
<Tag.Label>{x}</Tag.Label>
|
||||
</Tag.Root>
|
||||
);
|
||||
})}</HStack>
|
||||
<ArtistCreditTags data={play.data.albumArtists} />
|
||||
</DataList.ItemValue>
|
||||
</DataList.Item>
|
||||
);
|
||||
@@ -133,7 +128,7 @@ export const PlayDataDataList = (props: { play: JsonPlayObject, dates: DisplayDa
|
||||
} = {}
|
||||
} = play;
|
||||
|
||||
let titleElm: JSX.Element;
|
||||
let titleElm: React.JSX.Element;
|
||||
if(webUrl !== undefined || originUrl !== undefined) {
|
||||
titleElm = <Link variant="underline" target="_blank" href={webUrl ?? originUrl}>{track}</Link>
|
||||
} else {
|
||||
@@ -151,13 +146,7 @@ export const PlayDataDataList = (props: { play: JsonPlayObject, dates: DisplayDa
|
||||
<DataList.ItemLabel>Artists</DataList.ItemLabel>
|
||||
<DataList.ItemValue>
|
||||
{artists.length === 0 ? <Text color="fg.muted">(No Artists)</Text> :
|
||||
<HStack>{play.data.artists.map((x, index) => {
|
||||
return (
|
||||
<Tag.Root key={index}>
|
||||
<Tag.Label>{x}</Tag.Label>
|
||||
</Tag.Root>
|
||||
);
|
||||
})}</HStack>}
|
||||
<ArtistCreditTags data={play.data.artists} />}
|
||||
</DataList.ItemValue>
|
||||
</DataList.Item>
|
||||
{albumArtistElm}
|
||||
@@ -210,7 +199,7 @@ export const PlayDatesStack = (props: { play: JsonPlayObject, dates: DisplayDate
|
||||
dates
|
||||
} = props;
|
||||
|
||||
let datesItem: JSX.Element | null;
|
||||
let datesItem: React.JSX.Element | null;
|
||||
if (dates === false) {
|
||||
datesItem = null;
|
||||
} else {
|
||||
@@ -246,11 +235,11 @@ export const PlayDatesFooter = (props: { play: JsonPlayObject, dates: DisplayDat
|
||||
dates
|
||||
} = props;
|
||||
|
||||
let dateElm: JSX.Element;
|
||||
let dateElm: React.JSX.Element;
|
||||
|
||||
if (dates !== false) {
|
||||
let playDate: JSX.Element,
|
||||
seenDate: JSX.Element;
|
||||
let playDate: React.JSX.Element,
|
||||
seenDate: React.JSX.Element;
|
||||
if (play.data.playDate !== undefined && ['all', 'played'].includes(dates)) {
|
||||
playDate = <Text textStyle="xs" color="fg.muted">{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</Text>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { IoMusicalNoteOutline } from "react-icons/io5";
|
||||
import { HiOutlineCloudUpload, HiOutlineCloudDownload } from "react-icons/hi";
|
||||
import { LuCheck, LuCircleX, LuX } from "react-icons/lu";
|
||||
import { ChakraCodeBlockShort, ChakraPlainBlock, ChakraPlainBlockShort } from "./CodeBlock";
|
||||
import { JsonDiffPatch } from "./JsonDiff";
|
||||
import { formatNumber, jdiff } from "../../core/DataUtils";
|
||||
import { capitalize } from "../../core/StringUtils";
|
||||
import { MSCollapsible, MSCollapsibleExternalProps } from "./MSCollapsible";
|
||||
@@ -33,9 +32,9 @@ export const ScrobbleActionResult = (props: ScrobbleActionResultProps) => {
|
||||
collapsibleOpen
|
||||
} = props;
|
||||
|
||||
let responseSuffix: JSX.Element,
|
||||
warningsElm: JSX.Element,
|
||||
errorElm: JSX.Element | null;
|
||||
let responseSuffix: React.JSX.Element,
|
||||
warningsElm: React.JSX.Element,
|
||||
errorElm: React.JSX.Element | null;
|
||||
|
||||
if (warnings.length > 0) {
|
||||
warningsElm = <Span color="orange.solid">warnings</Span>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { BiWrench } from "react-icons/bi";
|
||||
import { IoMusicalNoteOutline } from "react-icons/io5";
|
||||
import { LuCheck, LuCircleX, LuX } from "react-icons/lu";
|
||||
import { ChakraCodeBlockShort, ChakraPlainBlock, ChakraPlainBlockShort } from "./CodeBlock";
|
||||
import { JsonDiffPatch } from "./JsonDiff";
|
||||
import { formatNumber, jdiff } from "../../core/DataUtils";
|
||||
|
||||
export interface ScrobbleMatchResultProps {
|
||||
|
||||
@@ -8,22 +8,22 @@ import { BsStoplights } from "react-icons/bs";
|
||||
import { BsExclamationTriangle, BsExclamationOctagon } from "react-icons/bs";
|
||||
import { MdMusicNote } from "react-icons/md";
|
||||
import { ChakraCodeBlockShort, ChakraPlainBlockShort } from "./CodeBlock";
|
||||
import { JsonDiffPatch } from "./JsonDiff";
|
||||
import { jdiff, patchObject } from "../../core/DataUtils";
|
||||
import { patchObject } from "../../core/DataUtils";
|
||||
import { MSCollapsible, MSCollapsibleExternalProps } from "./MSCollapsible";
|
||||
import { Muted } from "./Typography";
|
||||
import { JsonDiffPatch } from "./diffs/JsonDiff";
|
||||
|
||||
export interface LifeycleStepsTimelineProps extends MSCollapsibleExternalProps {
|
||||
steps: LifecycleStep[]
|
||||
original: JsonPlayObject
|
||||
}
|
||||
|
||||
const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [JSX.Element[], JsonPlayObject?] => {
|
||||
const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [React.JSX.Element[], JsonPlayObject?] => {
|
||||
|
||||
let currentPlay: JsonPlayObject = structuredClone(original); // JSON.parse(JSON.stringify(original));
|
||||
let patchFailed = false;
|
||||
|
||||
const diffElements: JSX.Element[] | null = [];
|
||||
const diffElements: React.JSX.Element[] | null = [];
|
||||
let index = 0;
|
||||
|
||||
for (const step of steps) {
|
||||
@@ -63,9 +63,7 @@ const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [JSX.El
|
||||
try {
|
||||
currentPlay.data = patchObject(currentPlay.data, patch)// jdiff.patch(currentPlay, patch) as JsonPlayObject;
|
||||
diffElements.push(
|
||||
<ChakraPlainBlockShort title="Play Diff" key={`diffblock-${index}`} code={left.data}>
|
||||
<JsonDiffPatch key={`diff-${index}`} left={left.data} right={currentPlay.data} />
|
||||
</ChakraPlainBlockShort>
|
||||
<JsonDiffPatch left={left.data} right={currentPlay.data}/>
|
||||
)
|
||||
} catch (e) {
|
||||
diffElements.push(<Fragment><ErrorAlert error={e} /><ChakraCodeBlockShort title="Diff Patch" key={`diffblockfallback-${index}`} code={patch} /></Fragment>);
|
||||
@@ -100,9 +98,9 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => {
|
||||
name
|
||||
} = x;
|
||||
|
||||
let timelineIcon: JSX.Element,
|
||||
let timelineIcon: React.JSX.Element,
|
||||
iconProps: Record<string, any>,
|
||||
summary: JSX.Element,
|
||||
summary: React.JSX.Element,
|
||||
alertStatus: "error" | "info" | "warning" | "success" | "neutral";
|
||||
if(error === undefined) {
|
||||
timelineIcon = <BsStoplights/>;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { ComponentProps } from 'react';
|
||||
import {
|
||||
parseDiffFromFile,
|
||||
type FileContents
|
||||
} from '@pierre/diffs';
|
||||
import {
|
||||
type FileDiffMetadata,
|
||||
type FileDiffProps,
|
||||
FileDiff,
|
||||
} from '@pierre/diffs/react';
|
||||
import { MarkRequired } from 'ts-essentials';
|
||||
import { Changeset } from 'json-diff-ts';
|
||||
import { MSErrorBoundary } from '../ErrorBoundary';
|
||||
import { patchObject } from "../../../core/DataUtils";
|
||||
|
||||
type DiffableVal = [] | object | string;
|
||||
|
||||
interface JsonDiffOptionalProps {
|
||||
right?: DiffableVal,
|
||||
left: DiffableVal
|
||||
diff?: Changeset
|
||||
diffOpts?: FileDiffProps<undefined>
|
||||
}
|
||||
|
||||
export type JsonDiffProps = MarkRequired<JsonDiffOptionalProps, 'diff'> | MarkRequired<JsonDiffOptionalProps, 'right'>
|
||||
|
||||
const JsonDiffPatchComponent = (props: JsonDiffProps) => {
|
||||
const {
|
||||
right: rightVal,
|
||||
left,
|
||||
diff,
|
||||
diffOpts = {},
|
||||
} = props;
|
||||
|
||||
if(rightVal === undefined && diff === undefined) {
|
||||
throw new Error(`Must provide either 'right' or 'diff' props`);
|
||||
}
|
||||
|
||||
let right = rightVal;
|
||||
if(right === undefined) {
|
||||
right = patchObject(structuredClone(left), diff);
|
||||
}
|
||||
|
||||
const fileDiff: FileDiffMetadata = parseDiffFromFile(valToFileContents(left), valToFileContents(right));
|
||||
return <FileDiff
|
||||
// Required: pre-parsed FileDiffMetadata
|
||||
fileDiff={fileDiff}
|
||||
options={{
|
||||
theme: { dark: 'github-dark', light: 'github-light' },
|
||||
diffStyle: 'unified',
|
||||
themeType: 'system',
|
||||
lineDiffType: 'word-alt',
|
||||
disableFileHeader: true,
|
||||
disableLineNumbers: true,
|
||||
...diffOpts
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
export const JsonDiffPatch = (props: JsonDiffProps) => <MSErrorBoundary><JsonDiffPatchComponent {...props}/></MSErrorBoundary>
|
||||
|
||||
const valToFileContents = (val: DiffableVal, fileContentOpts: Partial<FileContents> = {}): FileContents => {
|
||||
let strContent: string;
|
||||
const {
|
||||
name = 'Content'
|
||||
} = fileContentOpts;
|
||||
let fileName: string = name;
|
||||
let lang: FileContents['lang'];
|
||||
if(typeof val === 'string') {
|
||||
strContent = val;
|
||||
} else {
|
||||
strContent = JSON.stringify(val, undefined, 2);
|
||||
lang = 'json';
|
||||
}
|
||||
return {
|
||||
name: fileName,
|
||||
lang,
|
||||
contents: strContent
|
||||
};
|
||||
}
|
||||
@@ -168,7 +168,7 @@ const VirtualizedCollapse = (props: { data: PlayActivity[] }) => {
|
||||
</Collapsible.Indicator>
|
||||
<Stack gap="1" truncate alignItems="flex-start">
|
||||
<Span>{play.data.track}</Span>
|
||||
<TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted>
|
||||
<TextMuted truncate>{play.data.artists.map(x => x.name).join(' / ')}</TextMuted>
|
||||
<HStack gap="1">
|
||||
<ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" />
|
||||
<TextMuted>{play.meta?.source}</TextMuted>
|
||||
@@ -260,7 +260,7 @@ const VirtualizedAccordian = (props: { data: PlayActivity[] }) => {
|
||||
<Accordion.ItemIndicator />
|
||||
<Stack gap="1" truncate>
|
||||
<Span>{play.data.track}</Span>
|
||||
<TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted>
|
||||
<TextMuted truncate>{play.data.artists.map(x => x.name).join(' / ')}</TextMuted>
|
||||
<HStack gap="1">
|
||||
<ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" />
|
||||
<TextMuted>{play.meta?.source}</TextMuted>
|
||||
@@ -331,7 +331,7 @@ const PlainAccordian = (props: { data: PlayActivity[], sortBy: 'played' | 'seen'
|
||||
<Accordion.ItemIndicator />
|
||||
<Stack gap="1" truncate>
|
||||
<Span>{play.data.track}</Span>
|
||||
<TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted>
|
||||
<TextMuted truncate>{play.data.artists.map(x => x.name).join(' / ')}</TextMuted>
|
||||
<HStack gap="1">
|
||||
<ShortDateDisplay date={sortBy === 'played' ? play.data.playDate : play.meta?.seenAt} prefix={sortBy === 'played' ? 'Played' : 'Seen'} /><Separator orientation="vertical" height="4" />
|
||||
<TextMuted>{play.meta?.source}</TextMuted>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { faQuestionCircle, faBug } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useCopyToClipboard } from '../components/copyToClipboardHook';
|
||||
import clsx from "clsx";
|
||||
import {patch} from 'jsondiffpatch';
|
||||
|
||||
const displayOpts = {
|
||||
include: recentIncludes,
|
||||
|
||||
+3
-2
@@ -2,7 +2,6 @@ import { LogDataPretty, LogLevel } from "@foxxmd/logging";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { ListenProgress } from "../backend/sources/PlayerState/ListenProgress.js";
|
||||
import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js";
|
||||
import { Delta } from 'jsondiffpatch';
|
||||
import { MarkOptional } from "ts-essentials";
|
||||
import { ErrorObject } from "serialize-error";
|
||||
import { PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js";
|
||||
@@ -621,4 +620,6 @@ export const CLIENT_DEAD_QUEUE = 'dead';
|
||||
*/
|
||||
export type TypesAreEqual<T, U, Y=unknown, N=never> =
|
||||
(<G>() => G extends T ? 1 : 2) extends
|
||||
(<G>() => G extends U ? 1 : 2) ? Y : N;
|
||||
(<G>() => G extends U ? 1 : 2) ? Y : N;
|
||||
|
||||
export type MBID = `${string}-${string}-${string}-${string}-${string}`
|
||||
@@ -5,7 +5,7 @@ import isBetween from "dayjs/plugin/isBetween.js";
|
||||
import relativeTime from "dayjs/plugin/relativeTime.js";
|
||||
import timezone from "dayjs/plugin/timezone.js";
|
||||
import utc from "dayjs/plugin/utc.js";
|
||||
import { BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "./Atomic.js";
|
||||
import { ArtistCredit, BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MBID, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "./Atomic.js";
|
||||
import { genGroupIdStr } from './PlayUtils.js';
|
||||
import { sortByNewestPlayDate } from './PlayUtils.js';
|
||||
import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, SINGLE_USER_PLATFORM_ID } from '../backend/common/infrastructure/Atomic.js';
|
||||
@@ -17,6 +17,7 @@ import { LastFMTrackObject } from '../backend/common/vendor/LastfmApiClient.js';
|
||||
import { MarkOptional } from 'ts-essentials';
|
||||
import { defaultLifecycle } from '../backend/utils/PlayTransformUtils.js';
|
||||
import clone from 'clone';
|
||||
import { removeUndefinedKeys } from '../backend/utils.js';
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
@@ -247,7 +248,7 @@ export const generateJsonPlays = (...args: Parameters<typeof generatePlays>): Js
|
||||
}
|
||||
|
||||
export interface WithBrainzOptions {
|
||||
include: ('track' | 'artist' | 'album')[]
|
||||
include: ('track' | 'artist' | 'album' | 'recording')[]
|
||||
}
|
||||
export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): BrainzMeta => {
|
||||
const {include} = opts;
|
||||
@@ -255,6 +256,11 @@ export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): Brain
|
||||
for(const i of include) {
|
||||
switch(i) {
|
||||
case 'track':
|
||||
if(play.data.meta?.brainz?.track === undefined) {
|
||||
brainz.track = generateMbid();
|
||||
}
|
||||
break;
|
||||
case 'recording':
|
||||
if(play.data.meta?.brainz?.recording === undefined) {
|
||||
brainz.recording = generateMbid();
|
||||
}
|
||||
@@ -339,6 +345,21 @@ export const generateArtists = (num?: number, max: number = 3, opts: ArtistGener
|
||||
return artists;
|
||||
}
|
||||
|
||||
export const generateArtistCredit = (name: string = faker.music.artist(), mbidVal: MBID | boolean = true): ArtistCredit => {
|
||||
let mbid: MBID;
|
||||
if(mbidVal === true) {
|
||||
mbid = generateMbid();
|
||||
} else if(typeof mbidVal === 'string') {
|
||||
mbid = mbidVal as MBID;
|
||||
}
|
||||
return removeUndefinedKeys<ArtistCredit>({name, mbid});
|
||||
}
|
||||
|
||||
export const generateArtistCredits = (num?: number, max?: number, opts: ArtistGenerationOptions & {mbidVal?: boolean} = {}): ArtistCredit[] => {
|
||||
const artistNames = generateArtists(num, max, opts);
|
||||
return artistNames.map(x => generateArtistCredit(x, opts.mbidVal));
|
||||
}
|
||||
|
||||
export interface ArtistGenerateOptions extends ArtistGenerationOptions {
|
||||
num?: number
|
||||
max?: number
|
||||
@@ -442,14 +463,14 @@ export const generateLastfmTrackObject = (): LastFMTrackObject => {
|
||||
}
|
||||
}
|
||||
|
||||
export const generateMbid = (): string => {
|
||||
export const generateMbid = (): MBID => {
|
||||
return [
|
||||
faker.string.alphanumeric({length: 8}),
|
||||
faker.string.alphanumeric({length: 4}),
|
||||
faker.string.alphanumeric({length: 4}),
|
||||
faker.string.alphanumeric({length: 4}),
|
||||
faker.string.alphanumeric({length: 12})
|
||||
].join('-')
|
||||
].join('-') as MBID;
|
||||
}
|
||||
|
||||
export const generateTealPlayRecord = (opts: {
|
||||
|
||||
+21
-2
@@ -16,6 +16,7 @@ import {
|
||||
} from "./Atomic.js";
|
||||
import { DELIMETERS_REGEX, DELIMITERS } from "../backend/common/infrastructure/Atomic.js";
|
||||
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
|
||||
import { removeUndefinedKeys } from "../backend/utils.js";
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
@@ -309,7 +310,25 @@ export const containsDelimiters = (str: string) => null !== str.match(/[,&/\\]+/
|
||||
const NUMBERS_REGEX = new RegExp(/^\s*\d+\s*$/);
|
||||
export const stringIsOnlyNumbers = (str: string) => NUMBERS_REGEX.test(str);
|
||||
|
||||
export const artistNamesToCredits = (names: string[] | undefined): ArtistCredit[] => names === undefined ? undefined : names.map((x) => ({name: x}));
|
||||
export const artistNameToCredit = (name: string | undefined): ArtistCredit => name === undefined ? undefined : ({ name });
|
||||
export const artistNamesToCredits = (names: (string | Partial<ArtistCredit>)[] | undefined): ArtistCredit[] => {
|
||||
if(names === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return names.map(artistNameToCredit).filter(x => x !== undefined);
|
||||
};
|
||||
export const artistNameToCredit = (val: string | undefined | Partial<ArtistCredit>): ArtistCredit => {
|
||||
if(val === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if(typeof val === 'string') {
|
||||
return {name: val};
|
||||
}
|
||||
const {
|
||||
name,
|
||||
mbid,
|
||||
...rest
|
||||
} = val;
|
||||
return removeUndefinedKeys({name, mbid, ...rest});
|
||||
}
|
||||
export const artistCreditToName = (a: ArtistCredit): string => a.name;
|
||||
export const artistCreditsToNames = (a: ArtistCredit[]): string[] => a.map((x) => x.name);
|
||||
@@ -219,7 +219,7 @@ export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleO
|
||||
}
|
||||
somethingModified = true;
|
||||
if(ctx.key === 'brainz' && Object.keys(x ?? {}).length === 0) {
|
||||
ctx.update(generateBrainz(play, {include: ['album', 'artist', 'track']}), true);
|
||||
ctx.update(generateBrainz(play, {include: ['album', 'artist', 'track','recording']}), true);
|
||||
} else if (ctx.parent !== undefined && ctx.parent.key === 'brainz') {
|
||||
if (Array.isArray(x)) {
|
||||
ctx.update(faker.helpers.multiple(generateMbid, { count: { min: 1, max: 3 } }));
|
||||
|
||||
@@ -5,14 +5,16 @@ import { fn } from 'storybook/test';
|
||||
import { PlayData } from "../client/components/PlayData.js";
|
||||
import {Provider} from "../client/components/Provider";
|
||||
import { Container } from '@chakra-ui/react';
|
||||
import { generateArtists, generateJsonPlay, generatePlay, withBrainz } from "../core/PlayTestUtils.js"
|
||||
import { generateArtistCredits, generateArtists, generateJsonPlay, generatePlay, withBrainz } from "../core/PlayTestUtils.js"
|
||||
import clone from "clone";
|
||||
import { asJsonPlayObject } from '../core/PlayMarshalUtils.js';
|
||||
import { JsonPlayObject } from "../core/Atomic.js";
|
||||
|
||||
type PropsAndCustomArgs = React.ComponentProps<typeof PlayData> & {
|
||||
includeAlbumArtists?: boolean;
|
||||
defaultFinal?: boolean
|
||||
brainz?: boolean
|
||||
play: JsonPlayObject
|
||||
};
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
|
||||
const meta = preview.type<{args: PropsAndCustomArgs}>().meta({
|
||||
@@ -48,7 +50,7 @@ export const PlayInfoStory = meta.story({
|
||||
}
|
||||
|
||||
if(args.includeAlbumArtists && (args.play.data.albumArtists === undefined || args.play.data.albumArtists.length === 0)) {
|
||||
const aa = generateArtists(undefined, 2);
|
||||
const aa = generateArtistCredits(undefined, 2, {mbidVal: true});
|
||||
args.play.data.albumArtists = aa;
|
||||
if(args.final !== undefined) {
|
||||
args.final.data.albumArtists = aa;
|
||||
@@ -57,7 +59,7 @@ export const PlayInfoStory = meta.story({
|
||||
|
||||
if(args.brainz) {
|
||||
// @ts-ignore
|
||||
args.play = asJsonPlayObject(withBrainz(args.play, {include: ['album','track','artist']}));
|
||||
args.play = asJsonPlayObject(withBrainz(args.play, {include: ['album','recording','track','artist']}));
|
||||
}
|
||||
return (<PlayData {...args}/>)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fn } from 'storybook/test';
|
||||
import { Container } from '@chakra-ui/react';
|
||||
import { TransformSteps } from "../client/components/TransformSteps.js";
|
||||
import {Provider} from "../client/components/Provider";
|
||||
import { generateJsonPlays } from "../core/PlayTestUtils.js";
|
||||
import { generateJsonPlays, generatePlay } from "../core/PlayTestUtils.js";
|
||||
import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js";
|
||||
import { examplePlay, lastfmErrorExample } from "./storyUtils.js";
|
||||
import {generatePlayWithLifecycle} from '../core/tests/utils/fixtures'
|
||||
@@ -28,12 +28,13 @@ decorators: [
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args
|
||||
});
|
||||
|
||||
const multiPlay = asJsonPlayObject(generatePlayWithLifecycle({lifecycleSteps: { preCompare: 2}}));
|
||||
const original = generatePlay();
|
||||
const multiPlay = asJsonPlayObject(generatePlayWithLifecycle({lifecycleSteps: { preCompare: 2}, original}));
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const Multiple = meta.story({
|
||||
args: {
|
||||
steps: multiPlay.meta.lifecycle.steps,
|
||||
original: multiPlay.meta.lifecycle.original,
|
||||
steps: multiPlay.meta!.lifecycle!.steps,
|
||||
original: asJsonPlayObject(original),
|
||||
collapsibleOpen: true
|
||||
}
|
||||
//render: function Render(args) { return (<ChakraProvider><MyList></MyList></ChakraProvider>) }
|
||||
|
||||
Reference in New Issue
Block a user