Compare commits

...
Author SHA1 Message Date
FoxxMD f0e798c052 fix: Add missing artist credit transforms
Fixes #613
2026-06-03 13:24:32 +00:00
FoxxMD 44424f2570 fix: more logging and arg fixes after re-generating lock file 2026-06-03 13:24:10 +00:00
FoxxMD 72df520300 chore: re-generate lock file
* Caused by npm/cli#6301
* will make packaging for nix easier NixOS/nixpkgs#519119
2026-06-03 12:54:31 +00:00
FoxxMD 25c783051d fix: Fix argument order for logger labels 2026-06-03 12:50:16 +00:00
FoxxMD 264bc31af6 Merge branch 'storybookRefresh' 2026-06-02 14:11:04 +00:00
FoxxMD e0bdb8fe3c docs: Fix typo in jriver docs 2026-06-02 12:52:09 +00:00
FoxxMD 4d7c93f7dc fix(maloja): Fix source maloja envs
#610
2026-06-01 22:32:15 +00:00
FoxxMD dd54075c52 docs: Fix bad config example for id on aio in 0.14.0 upgrade 2026-06-01 19:37:02 +00:00
FoxxMD ff08b6327d docs: Comment out required upgrade callout, for now
Unnessary information since there is nothing > 0.14.0. May confuse some users. Can uncomment after 0.14.1 or later.
2026-06-01 19:27:09 +00:00
FoxxMD 76392a9668 chore(release): Bump version for release 2026-06-01 18:45:20 +00:00
FoxxMD f47faabf7c docs: Remove example of caching for transform best practices
Example is already in the main cache configuration docs. Consolidate/remove the transform example so there is only one authorative example in the docs.
2026-06-01 17:30:56 +00:00
FoxxMD 120e280fc8 docs: More upgrade guidance for 0.14.0 2026-06-01 15:22:38 +00:00
FoxxMD 6ae80fd881 feat(docs): Update colors for regular important admonition 2026-06-01 14:51:11 +00:00
FoxxMD be70e7074a fix(docs): Fix css for important admonition details important type 2026-06-01 14:35:02 +00:00
Matt Foxx 65841a8c6b Merge pull request #609 from HiiJax/fixMbIdCaps
fix(tealfm): trackMbid -> trackMbId
2026-06-01 08:40:14 -04:00
Jax 82374fc806 fix(tealfm): trackMbid -> trackMbId 2026-05-31 20:02:09 -04:00
FoxxMD 004400d7ea refactor: Replace jsondiffpatch-react with @pierre/diffs
Good ridance.
2026-05-30 20:02:20 +00:00
FoxxMD d54e169a14 fix for transformstep date 2026-05-30 19:05:36 +00:00
FoxxMD 31c35647dd feat(ui): Update artist rendering to use credits 2026-05-30 19:05:36 +00:00
FoxxMD 204ee159d6 fix(ui): Update JSX namespace to fix missing ambient in react 19 2026-05-30 19:05:36 +00:00
FoxxMD 4fe1f8ced9 chore(release): Bump for beta release 2026-05-30 15:12:25 +00:00
Matt Foxx c26e6f77f5 Merge pull request #608 from FoxxMD/nowPlayingGranular
refactor: Now Playing rewrite to handle realtime client updates
2026-05-30 10:59:40 -04:00
FoxxMD e2b59900e5 fix(listenbrainz): Fix artist credit mapping for minimum track data 2026-05-30 14:59:18 +00:00
FoxxMD c9cde11871 fix(tealfm): Missing function call to statusExpiresSoon 2026-05-29 14:45:42 +00:00
FoxxMD ea0c75ccf3 chore: Simplify gitignore pattern for db files 2026-05-29 14:40:57 +00:00
FoxxMD 13a64bd63a refactor(now playing): Improve now playing to generically handle clients that have real-time updates
* Break out player status checks and thresholds into separate functions
* Consolidate all should update functionality into shouldUpdatePlayingNow for easier testing
* Introduce realtime flag for teal/discord and check if clearing is possible/desired
  * Remove equivalent functionality in individual clients
* (tealfm): Correctly set max threshold using track duration
* (tealfm): Skip clearing update if close to/after expiration time
* (tealfm): Increase min threshold to avoid spamming pds updates for pauses/skip track
2026-05-29 14:40:45 +00:00
FoxxMD c3b23271d4 fix(now playing): Fix acessing undefined play when source player state is reported but without any data
598#issuecomment-4566553387
2026-05-28 17:48:05 +00:00
FoxxMD d047c600c2 feat(tealfm): Update lexicon defs with new mbid uri format
teal-fm/teal#102
2026-05-28 16:13:06 +00:00
FoxxMD b6cae4a538 fix(jellyfin): Handle string or object data for album props when parsing to play
#598#issuecomment-4564522030
2026-05-28 15:08:14 +00:00
FoxxMD 1eed48456c feat: Make artist name to credit function more robust
* artistNameToCredit can now handle partial ArtistCredit object
* Re-use artistNameToCredit for mapping in artistNameToCredits
2026-05-28 15:07:19 +00:00
FoxxMD 20834e812e feat: case-insensitive proxy handles spread/rest 2026-05-28 14:44:02 +00:00
54 changed files with 5468 additions and 3899 deletions
+1 -5
View File
@@ -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):
+64 -24
View File
@@ -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.
:::
+10 -2
View File
@@ -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;
+4658 -3533
View File
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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')
}
+164 -39
View File
@@ -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`];
}
+14 -27
View File
@@ -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];
}
}
+1 -1
View File
@@ -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";
+34 -25
View File
@@ -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);
}
}
+1 -1
View File
@@ -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';
+3 -3
View File
@@ -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...');
+1 -1
View File
@@ -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;
}
}
+13 -10
View File
@@ -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 = {
+8 -7
View File
@@ -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;
+2 -2
View File
@@ -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)) {
+1 -1
View File
@@ -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;
+3 -2
View File
@@ -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,
+5 -4
View File
@@ -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
+87 -1
View File
@@ -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;
});
+18 -2
View File
@@ -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}`);
});
});
+12
View File
@@ -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() {
+34 -18
View File
@@ -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 = {}) {
+1 -1
View File
@@ -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>
}
+46
View File
@@ -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>
)
},
)
+1 -1
View File
@@ -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 {
-17
View File
@@ -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;
} */
-43
View File
@@ -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 -2
View File
@@ -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
+11 -22
View File
@@ -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 {
+7 -9
View File
@@ -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/>;
+80
View File
@@ -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>
-1
View File
@@ -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
View File
@@ -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}`
+25 -4
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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 -3
View File
@@ -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 -4
View File
@@ -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>) }