[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: Separating functions so they are usuable by frontend storybook

FoxxMD (Mar 20, 2026, 7:33 PM UTC) 5ebb0e6a dfb7f43c

+305 -278
+1 -1
src/backend/common/Cache.ts
··· 17 17 import { asCacheAuthProvider, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js'; 18 18 import { Typeson } from 'typeson'; 19 19 import { builtin } from 'typeson-registry'; 20 - import { loggerNoop } from './logging.js'; 20 + import { loggerNoop } from './MaybeLogger.js'; 21 21 import { ListenProgressPositional, ListenProgressTS } from '../sources/PlayerState/ListenProgress.js'; 22 22 const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); 23 23
+67
src/backend/common/MaybeLogger.ts
··· 1 + import { Logger } from "@foxxmd/logging"; 2 + 3 + 4 + export class MaybeLogger { 5 + logger?: Logger; 6 + 7 + constructor(logger?: Logger, label?: string) { 8 + if (logger !== undefined && label !== undefined) { 9 + this.logger = logger;//this.logger = childLogger(logger, label); 10 + } else { 11 + this.logger = logger; 12 + } 13 + } 14 + 15 + public info(first: any, ...rest: any) { 16 + if (this.logger) { 17 + this.logger.info(first, ...rest); 18 + } 19 + } 20 + 21 + public debug(first: any, ...rest: any) { 22 + if (this.logger) { 23 + this.logger.debug(first, ...rest); 24 + } 25 + } 26 + 27 + public trace(first: any, ...rest: any) { 28 + if (this.logger) { 29 + this.logger.trace(first, ...rest); 30 + } 31 + } 32 + 33 + public warn(first: any, ...rest: any) { 34 + if (this.logger) { 35 + this.logger.warn(first, ...rest); 36 + } 37 + } 38 + 39 + public verbose(first: any, ...rest: any) { 40 + if (this.logger) { 41 + this.logger.verbose(first, ...rest); 42 + } 43 + } 44 + 45 + public error(first: any, ...rest: any) { 46 + if (this.logger) { 47 + this.logger.error(first, ...rest); 48 + } 49 + } 50 + } 51 + export const noopLog = (_: any, ...rest: any) => undefined; 52 + 53 + export const loggerNoop: Logger = { 54 + trace: noopLog, 55 + debug: noopLog, 56 + log: noopLog, 57 + info: noopLog, 58 + verbose: noopLog, 59 + warn: noopLog, 60 + error: noopLog, 61 + fatal: noopLog, 62 + silent: noopLog, 63 + level: 'silent', 64 + child: (_: any, ...rest: any) => loggerNoop as Logger 65 + } as unknown as Logger; 66 + 67 +
+1 -1
src/backend/common/errors/UpstreamError.ts
··· 12 12 13 13 constructor(message: string, options?: UpstreamErrorOptions | undefined) { 14 14 super(message, options); 15 - const {showStopper = false, response, responseBody} = options; 15 + const {showStopper = false, response, responseBody} = options ?? {}; 16 16 this.showStopper = showStopper; 17 17 this.response = response; 18 18 this.responseBody = responseBody;
+1 -2
src/backend/common/infrastructure/Atomic.ts
··· 1 1 import { Logger } from '@foxxmd/logging'; 2 - import { SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core"; 3 2 import { Dayjs, ManipulateType } from "dayjs"; 4 3 import { Request, Response } from "express"; 5 4 import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core"; 6 5 import { FixedSizeList } from 'fixed-size-list'; 7 - import { isPlayObject, PlayMeta, PlayMetaLifecycleless, PlayObject, PlayObjectLifecycleless, UnixTimestamp } from "../../../core/Atomic.js"; 6 + import { isPlayObject, PlayMetaLifecycleless, PlayObject, PlayObjectLifecycleless, UnixTimestamp } from "../../../core/Atomic.js"; 8 7 import TupleMap from "../TupleMap.js"; 9 8 import { MusicBrainzApi } from 'musicbrainz-api'; 10 9 import { SourceType } from './config/source/sources.js';
+1 -57
src/backend/common/logging.ts
··· 1 - import { childLogger, FileLogOptions, Logger, loggerAppRolling, LogLevel, LogLevelStreamEntry, LogOptions, parseLogOptions } from '@foxxmd/logging'; 1 + import { FileLogOptions, Logger, loggerAppRolling, LogLevel, LogLevelStreamEntry, LogOptions, parseLogOptions } from '@foxxmd/logging'; 2 2 import { buildDestinationJsonPrettyStream, buildDestinationRollingFile, buildDestinationStdout, buildLogger } from "@foxxmd/logging/factory"; 3 3 import { PassThrough, Transform } from "node:stream"; 4 4 import path from "path"; ··· 74 74 } 75 75 } 76 76 77 - export class MaybeLogger { 78 - logger?: Logger 79 77 80 - constructor(logger?: Logger, label?: string) { 81 - if (logger !== undefined && label !== undefined) { 82 - this.logger = childLogger(logger, label); 83 - } else { 84 - this.logger = logger; 85 - } 86 - } 87 - 88 - public info(first: any, ...rest: any) { 89 - if (this.logger) { 90 - this.logger.info(first, ...rest); 91 - } 92 - } 93 - 94 - public debug(first: any, ...rest: any) { 95 - if (this.logger) { 96 - this.logger.debug(first, ...rest); 97 - } 98 - } 99 - 100 - public warn(first: any, ...rest: any) { 101 - if (this.logger) { 102 - this.logger.warn(first, ...rest); 103 - } 104 - } 105 - 106 - public verbose(first: any, ...rest: any) { 107 - if (this.logger) { 108 - this.logger.verbose(first, ...rest); 109 - } 110 - } 111 - 112 - public error(first: any, ...rest: any) { 113 - if (this.logger) { 114 - this.logger.error(first, ...rest); 115 - } 116 - } 117 - } 118 - 119 - const noopLog = (_: any, ...rest: any) => undefined; 120 - 121 - export const loggerNoop: Logger = { 122 - trace: noopLog, 123 - debug: noopLog, 124 - log: noopLog, 125 - info: noopLog, 126 - verbose: noopLog, 127 - warn: noopLog, 128 - error: noopLog, 129 - fatal: noopLog, 130 - silent: noopLog, 131 - level: 'silent', 132 - child: (_: any, ...rest: any) => loggerNoop as Logger 133 - } as unknown as Logger;
+1 -1
src/backend/common/transforms/MusicbrainzTransformer.ts
··· 5 5 import AtomicPartsTransformer from "./AtomicPartsTransformer.js"; 6 6 import { TransformerOptions } from "./AbstractTransformer.js"; 7 7 import { ARTIST_WEIGHT, DELIMITERS, MUSICBRAINZ_URL, MusicbrainzApiConfigData, TITLE_WEIGHT } from "../infrastructure/Atomic.js"; 8 - import { MaybeLogger } from "../logging.js"; 8 + import { MaybeLogger } from '../MaybeLogger.js'; 9 9 import { childLogger, Logger } from "@foxxmd/logging"; 10 10 import { MusicbrainzApiClient, MusicbrainzApiConfig, recordingToPlay, UsingTypes } from "../vendor/musicbrainz/MusicbrainzApiClient.js"; 11 11 import { IRecordingList, IRecordingMatch, IRelease, MusicBrainzApi } from "musicbrainz-api";
+1 -1
src/backend/common/transforms/NativeTransformer.ts
··· 8 8 import { TransformerOptions } from "./AbstractTransformer.js"; 9 9 import { DELIMITERS_NO_AMP } from "../infrastructure/Atomic.js"; 10 10 import { asArray } from "../../utils/DataUtils.js"; 11 - import { MaybeLogger } from "../logging.js"; 11 + import { MaybeLogger } from '../MaybeLogger.js'; 12 12 import { childLogger } from "@foxxmd/logging"; 13 13 14 14 export type ArtistParseSource = 'artists' | 'title'
+4 -165
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 12 12 uniqueNormalizedStrArr, 13 13 } from "../../utils/StringUtils.js"; 14 14 import { findDelimiters } from "../../../core/StringUtils.js"; 15 - import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js"; 16 15 import { UpstreamError } from "../errors/UpstreamError.js"; 17 16 import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, DELIMITERS, FormatPlayObjectOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult } from "../infrastructure/Atomic.js"; 18 17 import { DEFAULT_ITEMS_PER_GET_LZ, ListenBrainzClientData, MAX_ITEMS_PER_GET_LZ } from "../infrastructure/config/client/listenbrainz.js"; 19 18 import AbstractApiClient from "./AbstractApiClient.js"; 20 19 import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js'; 21 - import { isEmptyArrayOrUndefined, removeUndefinedKeys, unique } from '../../utils.js'; 22 - import { version } from '../../ioc.js'; 23 - import { ListenPayload, ListenResponse, ListenType, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload } from './listenbrainz/interfaces.js'; 20 + import { removeUndefinedKeys, unique } from '../../utils.js'; 21 + import { ListenPayload, ListenResponse, ListenType, SubmitPayload } from './listenbrainz/interfaces.js'; 24 22 import { baseFormatPlayObj } from '../../utils/PlayTransformUtils.js'; 25 23 import { ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.js'; 26 24 import pRetry from 'p-retry'; 27 25 import { findCauseByFunc } from '../../utils/ErrorUtils.js'; 28 26 import { isSuperAgentResponseError } from '../errors/ErrorUtils.js'; 27 + import { playToSubmitPayload } from './listenbrainz/lzUtils.js'; 29 28 30 29 31 - interface SubmitOptions { 30 + export interface SubmitOptions { 32 31 log?: boolean 33 32 listenType?: ListenType 34 33 } ··· 721 720 } 722 721 723 722 724 - export const playToListenPayload = (play: PlayObject): ListenPayload => { 725 - const { 726 - data: { 727 - playDate, 728 - artists = [], 729 - // MB doesn't use this during submission AFAIK 730 - // instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release 731 - albumArtists = [], 732 - album, 733 - track, 734 - isrc, 735 - duration, 736 - meta: { 737 - brainz = {}, 738 - spotify = {}, 739 - } = {} 740 - }, 741 - meta: { 742 - mediaPlayerName, 743 - mediaPlayerVersion, 744 - musicService, 745 - source 746 - } 747 - } = play; 748 - // using submit-listens exmaple from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens 749 - // which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification 750 - // and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941 751 - 752 - const msAdditionalInfo = brainz.additionalInfo ?? {}; 753 - 754 - let addInfo: SubmitListenAdditionalTrackInfo = { 755 - // primary artists 756 - artist_names: Array.from(new Set([...artists])), 757 - // primary artist 758 - release_artist_name: albumArtists.length === 1 ? albumArtists[0] : undefined, 759 - release_artist_names: albumArtists.length > 0 ? albumArtists : undefined, 760 - // use data from LZ response, if this Play was originally from LZ Source 761 - media_player: mediaPlayerName ?? msAdditionalInfo.media_player, 762 - media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version, 763 - music_service: musicService !== undefined ? musicServiceToCononical(musicService) : msAdditionalInfo.music_service, 764 - music_service_name: musicService ?? source ?? msAdditionalInfo.music_service_name, 765 - spotify_id: msAdditionalInfo.spotify_id, 766 - spotify_album_id: msAdditionalInfo.spotify_album_id, 767 - spotify_artist_ids: msAdditionalInfo.spotify_artist_ids, 768 - origin_url: msAdditionalInfo.origin_url, 769 - isrc: isrc ?? msAdditionalInfo.isrc, 770 - tracknumber: brainz.trackNumber ?? msAdditionalInfo.tracknumber 771 - }; 772 - 773 - if(Object.keys(spotify).length > 0) { 774 - if(spotify.track !== undefined) { 775 - const trackUrl = `https://open.spotify.com/track/${spotify.track}`; 776 - if(addInfo.origin_url === undefined) { 777 - addInfo.origin_url = trackUrl; 778 - } 779 - if(addInfo.spotify_id === undefined) { 780 - addInfo.spotify_id = trackUrl; 781 - } 782 - } 783 - if(isEmptyArrayOrUndefined(addInfo.spotify_artist_ids) && !isEmptyArrayOrUndefined(spotify.artist)) { 784 - addInfo.spotify_artist_ids = spotify.artist.map(x => `https://open.spotify.com/artist/${x}`) 785 - } 786 - if(isEmptyArrayOrUndefined(addInfo.spotify_album_artist_ids) && !isEmptyArrayOrUndefined(spotify.albumArtist)) { 787 - addInfo.spotify_album_artist_ids = spotify.albumArtist.map(x => `https://open.spotify.com/artist/${x}`) 788 - } 789 - if(addInfo.spotify_album_id === undefined && spotify.album !== undefined) { 790 - addInfo.spotify_album_id = `https://open.spotify.com/album/${spotify.album}` 791 - } 792 - } 793 - 794 - addInfo = removeUndefinedKeys(addInfo) 795 - 796 - // possible lastfm provides an empty album field when no album data is found 797 - let al = album; 798 - if(al !== undefined && al !== null) { 799 - if(al.trim() === '') { 800 - al = undefined; 801 - } 802 - } 803 - 804 - const minTrackData = removeUndefinedKeys<MinimumTrack>({ 805 - artist_name: Array.from(new Set([...artists])).join(', '), 806 - track_name: track, 807 - release_name: al, 808 - }); 809 - 810 - return { 811 - listened_at: getScrobbleTsSOCDate(play).unix(), 812 - track_metadata: { 813 - ...minTrackData, 814 - additional_info: { 815 - duration: play.data.duration !== undefined ? Math.round(duration) : undefined, 816 - track_mbid: brainz.track, 817 - recording_mbid: brainz.recording, 818 - artist_mbids: brainz.artist, 819 - release_mbid: brainz.album, 820 - release_group_mbid: brainz.releaseGroup, 821 - submission_client: 'multi-scrobbler', 822 - submission_client_version: version, 823 - ...addInfo 824 - } 825 - } 826 - } 827 - } 828 - 829 - const musicServices = { 830 - spotify: 'spotify.com', 831 - bandcamp: 'bandcamp.com', 832 - ['youtube music']: 'music.youtube.com', 833 - youtube: 'youtube.com', 834 - deezer: 'deezer.com', 835 - tidal: 'tidal.com', 836 - apple: 'music.apple.com', 837 - archive: 'archive.org', 838 - soundcloud: 'soundcloud.com', 839 - jamendo: 'jamendo.com', 840 - play: 'play.google.com' 841 - } 842 - /** 843 - * Converts MS musicService to LZ cononical Music Service Name, if one exists 844 - * @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details 845 - * */ 846 - export const musicServiceToCononical = (str?: string): string | undefined => { 847 - if(str === undefined) { 848 - return undefined; 849 - } 850 - const lower = str.trim().toLocaleLowerCase(); 851 - for(const [k, v] of Object.entries(musicServices)) { 852 - if(lower.includes(k)) { 853 - return v; 854 - } 855 - } 856 - return undefined; 857 - } 858 - 859 - /** 860 - * Returns a known music service based on the given URL 861 - * @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details 862 - * */ 863 - export const urlToMusicService = (url?: string): string | undefined => { 864 - if(url === undefined) { 865 - return undefined; 866 - } 867 - const lower = url.trim().toLocaleLowerCase(); 868 - for(const [k, v] of Object.entries(musicServices)) { 869 - if(url.includes(v)) { 870 - return k; 871 - } 872 - } 873 - return undefined; 874 - } 875 - 876 - export const playToSubmitPayload = (play: PlayObject, options: SubmitOptions = {}): SubmitPayload => { 877 - const { listenType = 'single'} = options; 878 - const listenPayload: SubmitPayload = {listen_type: listenType, payload: [playToListenPayload(play)]}; 879 - if(listenType === 'playing_now') { 880 - delete listenPayload.payload[0].listened_at; 881 - } 882 - return listenPayload; 883 - }
+1 -1
src/backend/common/vendor/RockSkyApiClient.ts
··· 9 9 import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js'; 10 10 import { unique } from '../../utils.js'; 11 11 import { ListenPayload, ListenResponse, ListenType, SubmitPayload } from './listenbrainz/interfaces.js'; 12 - import { playToListenPayload } from './ListenbrainzApiClient.js'; 12 + import { playToListenPayload } from './listenbrainz/lzUtils.js'; 13 13 import { RockskyScrobble } from './rocksky/interfaces.js'; 14 14 import { Handle } from "@atcute/lexicons"; 15 15 import { identifierToAtProtoHandle } from './bluesky/bsUtils.js';
+1 -1
src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
··· 5 5 import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords } from "@atproto/api"; 6 6 import { MSCache } from "../../Cache.js"; 7 7 import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js"; 8 - import { musicServiceToCononical } from "../ListenbrainzApiClient.js"; 8 + import { musicServiceToCononical } from '../listenbrainz/lzUtils.js'; 9 9 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 10 10 import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; 11 11 import dayjs, { ManipulateType } from "dayjs";
+1 -1
src/backend/common/vendor/bluesky/bsUtils.ts
··· 2 2 import { isHandle } from "@atcute/lexicons/syntax"; 3 3 import { Logger } from "@foxxmd/logging"; 4 4 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 5 - import { MaybeLogger } from "../../logging.js"; 5 + import { MaybeLogger } from '../../MaybeLogger.js'; 6 6 import { AtprotoDid } from "@atproto/oauth-client-node"; 7 7 8 8 export const HANDLE_REGEX = new RegExp(/.+\..+/);
+1 -1
src/backend/common/vendor/discord/DiscordUtils.ts
··· 3 3 import { asPlayerStateData, SourceData } from "../../infrastructure/Atomic.js"; 4 4 import { GatewayActivity, GatewayOpcodes, PresenceUpdateStatus } from "discord.js"; 5 5 import { capitalize } from "../../../../core/StringUtils.js"; 6 - import { urlToMusicService } from "../ListenbrainzApiClient.js"; 6 + import { urlToMusicService } from '../listenbrainz/lzUtils.js'; 7 7 import { ACTIVITY_TYPE, ActivityData, ActivityTypes, DiscordData, DiscordStrongData, ActivityTypeString as MSActivityType, StatusType } from "../../infrastructure/config/client/discord.js"; 8 8 import { parseBool, removeUndefinedKeys } from "../../../utils.js"; 9 9 import { parseArrayFromMaybeString, parseBoolOrArrayFromMaybeString } from "../../../utils/StringUtils.js";
+1 -1
src/backend/common/vendor/koito/KoitoApiClient.ts
··· 6 6 import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; 7 7 import request, { Request, Response } from 'superagent'; 8 8 import { UpstreamError } from "../../errors/UpstreamError.js"; 9 - import { playToListenPayload } from "../ListenbrainzApiClient.js"; 9 + import { playToListenPayload } from '../listenbrainz/lzUtils.js'; 10 10 import { SubmitPayload } from '../listenbrainz/interfaces.js'; 11 11 import { ListenType } from '../listenbrainz/interfaces.js'; 12 12 import { parseRegexSingleOrFail } from "../../../utils.js";
+157
src/backend/common/vendor/listenbrainz/lzUtils.ts
··· 1 + import { PlayObject } from "../../../../core/Atomic.js"; 2 + import { isEmptyArrayOrUndefined, removeUndefinedKeys } from "../../../utils.js"; 3 + import { getScrobbleTsSOCDate } from "../../../utils/TimeUtils.js"; 4 + import { SubmitOptions } from "../ListenbrainzApiClient.js"; 5 + import { ListenPayload, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload } from "./interfaces.js"; 6 + import {version as appVersion } from '../../../version.js'; 7 + 8 + export const playToListenPayload = (play: PlayObject, version?: string): ListenPayload => { 9 + const { 10 + data: { 11 + playDate, artists = [], 12 + // MB doesn't use this during submission AFAIK 13 + // instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release 14 + albumArtists = [], album, track, isrc, duration, meta: { 15 + brainz = {}, spotify = {}, 16 + } = {} 17 + }, meta: { 18 + mediaPlayerName, mediaPlayerVersion, musicService, source 19 + } 20 + } = play; 21 + // using submit-listens exmaple from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens 22 + // which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification 23 + // and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941 24 + const msAdditionalInfo = brainz.additionalInfo ?? {}; 25 + 26 + let addInfo: SubmitListenAdditionalTrackInfo = { 27 + // primary artists 28 + artist_names: Array.from(new Set([...artists])), 29 + // primary artist 30 + release_artist_name: albumArtists.length === 1 ? albumArtists[0] : undefined, 31 + release_artist_names: albumArtists.length > 0 ? albumArtists : undefined, 32 + // use data from LZ response, if this Play was originally from LZ Source 33 + media_player: mediaPlayerName ?? msAdditionalInfo.media_player, 34 + media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version, 35 + music_service: musicService !== undefined ? musicServiceToCononical(musicService) : msAdditionalInfo.music_service, 36 + music_service_name: musicService ?? source ?? msAdditionalInfo.music_service_name, 37 + spotify_id: msAdditionalInfo.spotify_id, 38 + spotify_album_id: msAdditionalInfo.spotify_album_id, 39 + spotify_artist_ids: msAdditionalInfo.spotify_artist_ids, 40 + origin_url: msAdditionalInfo.origin_url, 41 + isrc: isrc ?? msAdditionalInfo.isrc, 42 + tracknumber: brainz.trackNumber ?? msAdditionalInfo.tracknumber 43 + }; 44 + 45 + if (Object.keys(spotify).length > 0) { 46 + if (spotify.track !== undefined) { 47 + const trackUrl = `https://open.spotify.com/track/${spotify.track}`; 48 + if (addInfo.origin_url === undefined) { 49 + addInfo.origin_url = trackUrl; 50 + } 51 + if (addInfo.spotify_id === undefined) { 52 + addInfo.spotify_id = trackUrl; 53 + } 54 + } 55 + if (isEmptyArrayOrUndefined(addInfo.spotify_artist_ids) && !isEmptyArrayOrUndefined(spotify.artist)) { 56 + addInfo.spotify_artist_ids = spotify.artist.map(x => `https://open.spotify.com/artist/${x}`); 57 + } 58 + if (isEmptyArrayOrUndefined(addInfo.spotify_album_artist_ids) && !isEmptyArrayOrUndefined(spotify.albumArtist)) { 59 + addInfo.spotify_album_artist_ids = spotify.albumArtist.map(x => `https://open.spotify.com/artist/${x}`); 60 + } 61 + if (addInfo.spotify_album_id === undefined && spotify.album !== undefined) { 62 + addInfo.spotify_album_id = `https://open.spotify.com/album/${spotify.album}`; 63 + } 64 + } 65 + 66 + addInfo = removeUndefinedKeys(addInfo); 67 + 68 + // possible lastfm provides an empty album field when no album data is found 69 + let al = album; 70 + if (al !== undefined && al !== null) { 71 + if (al.trim() === '') { 72 + al = undefined; 73 + } 74 + } 75 + 76 + const minTrackData = removeUndefinedKeys<MinimumTrack>({ 77 + artist_name: Array.from(new Set([...artists])).join(', '), 78 + track_name: track, 79 + release_name: al, 80 + }); 81 + 82 + return { 83 + listened_at: getScrobbleTsSOCDate(play).unix(), 84 + track_metadata: { 85 + ...minTrackData, 86 + additional_info: { 87 + duration: play.data.duration !== undefined ? Math.round(duration) : undefined, 88 + track_mbid: brainz.track, 89 + recording_mbid: brainz.recording, 90 + artist_mbids: brainz.artist, 91 + release_mbid: brainz.album, 92 + release_group_mbid: brainz.releaseGroup, 93 + submission_client: 'multi-scrobbler', 94 + submission_client_version: version ?? appVersion, 95 + ...addInfo 96 + } 97 + } 98 + }; 99 + }; 100 + export const musicServices = { 101 + spotify: 'spotify.com', 102 + bandcamp: 'bandcamp.com', 103 + ['youtube music']: 'music.youtube.com', 104 + youtube: 'youtube.com', 105 + deezer: 'deezer.com', 106 + tidal: 'tidal.com', 107 + apple: 'music.apple.com', 108 + archive: 'archive.org', 109 + soundcloud: 'soundcloud.com', 110 + jamendo: 'jamendo.com', 111 + play: 'play.google.com' 112 + }; 113 + /** 114 + * Converts MS musicService to LZ cononical Music Service Name, if one exists 115 + * @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details 116 + * */ 117 + 118 + 119 + export const musicServiceToCononical = (str?: string): string | undefined => { 120 + if (str === undefined) { 121 + return undefined; 122 + } 123 + const lower = str.trim().toLocaleLowerCase(); 124 + for (const [k, v] of Object.entries(musicServices)) { 125 + if (lower.includes(k)) { 126 + return v; 127 + } 128 + } 129 + return undefined; 130 + }; 131 + /** 132 + * Returns a known music service based on the given URL 133 + * @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details 134 + * */ 135 + 136 + 137 + export const urlToMusicService = (url?: string): string | undefined => { 138 + if (url === undefined) { 139 + return undefined; 140 + } 141 + const lower = url.trim().toLocaleLowerCase(); 142 + for (const [k, v] of Object.entries(musicServices)) { 143 + if (url.includes(v)) { 144 + return k; 145 + } 146 + } 147 + return undefined; 148 + }; 149 + export const playToSubmitPayload = (play: PlayObject, options: SubmitOptions = {}): SubmitPayload => { 150 + const { listenType = 'single' } = options; 151 + const listenPayload: SubmitPayload = { listen_type: listenType, payload: [playToListenPayload(play)] }; 152 + if (listenType === 'playing_now') { 153 + delete listenPayload.payload[0].listened_at; 154 + } 155 + return listenPayload; 156 + }; 157 +
+2 -1
src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts
··· 8 8 import { difference, isDebugMode, isEmptyArrayOrUndefined, sleep } from "../../../utils.js"; 9 9 import {SequentialRoundRobin} from 'round-robin-js'; 10 10 import { Cacheable } from "cacheable"; 11 - import { getRoot, version } from "../../../ioc.js"; 11 + import { getRoot } from "../../../ioc.js"; 12 + import { version } from "../../../version.js"; 12 13 import { hashObject } from "../../../utils/StringUtils.js"; 13 14 import { playContentInvariantTransform } from "../../../utils/PlayComparisonUtils.js"; 14 15 import { childLogger } from "@foxxmd/logging";;
+2 -1
src/backend/index.ts
··· 13 13 import { projectDir } from "./common/index.js"; 14 14 import { AIOConfig } from "./common/infrastructure/config/aioConfig.js"; 15 15 import { appLogger, initLogger as getInitLogger } from "./common/logging.js"; 16 - import { getRoot, parseVersion } from "./ioc.js"; 16 + import { getRoot } from "./ioc.js"; 17 + import { parseVersion } from "./version.js"; 17 18 import { initServer } from "./server/index.js"; 18 19 import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js"; 19 20 import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
+1 -9
src/backend/ioc.ts
··· 1 - import { getVersion } from "@foxxmd/get-version"; 2 1 import { Logger, loggerDebug, LogOptions } from "@foxxmd/logging"; 3 2 import { EventEmitter } from "events"; 4 3 import { createContainer } from "iti"; ··· 14 13 import { TransformerCommonConfig } from "../core/Atomic.js"; 15 14 import prom, { Counter, Gauge } from 'prom-client'; 16 15 import { CoverArtApiClient } from "./common/vendor/musicbrainz/CoverArtApiClient.js"; 17 - 18 - export let version: string = 'unknown'; 19 - 20 - export const parseVersion = async () => { 21 - version = await getVersion({priority: ['env', 'git', 'file']}); 22 - return version; 23 - } 16 + import { version } from "./version.js"; 24 17 25 18 let root: ReturnType<typeof createRoot>; 26 - 27 19 export interface RootOptions { 28 20 baseUrl?: string, 29 21 port?: number
+1 -3
src/backend/scrobblers/KoitoScrobbler.ts
··· 3 3 import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; 4 4 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 5 5 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 - import { UpstreamError } from "../common/errors/UpstreamError.js"; 7 6 import { FormatPlayObjectOptions, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js"; 8 - import { playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; 7 + import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js'; 9 8 import { Notifiers } from "../notifier/Notifiers.js"; 10 9 11 10 import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; ··· 13 12 import { KoitoClientConfig } from "../common/infrastructure/config/client/koito.js"; 14 13 import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js"; 15 14 import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js"; 16 - import dayjs from "dayjs"; 17 15 18 16 export default class KoitoScrobbler extends AbstractScrobbleClient { 19 17
+2 -1
src/backend/scrobblers/ListenbrainzScrobbler.ts
··· 7 7 import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; 8 8 import { FormatPlayObjectOptions, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js"; 9 9 import { DEFAULT_MS_ITEMS_PER_GET_LZ, ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js"; 10 - import { ListenbrainzApiClient, playToListenPayload, playToSubmitPayload } from "../common/vendor/ListenbrainzApiClient.js"; 10 + import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js"; 11 + import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js'; 11 12 import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; 12 13 import { Notifiers } from "../notifier/Notifiers.js"; 13 14
+2 -3
src/backend/scrobblers/RockskyScrobbler.ts
··· 3 3 import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; 4 4 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 5 5 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 - import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; 7 6 import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 8 - import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js"; 9 - import { ListenbrainzApiClient, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; 7 + import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js"; 8 + import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js'; 10 9 import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; 11 10 import { Notifiers } from "../notifier/Notifiers.js"; 12 11
+1 -1
src/backend/scrobblers/TealfmScrobbler.ts
··· 4 4 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 5 5 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 6 import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 7 - import { playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; 7 + import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js'; 8 8 import { Notifiers } from "../notifier/Notifiers.js"; 9 9 10 10 import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
+1 -1
src/backend/sources/ChromecastSource.ts
··· 17 17 SourceData, 18 18 } from "../common/infrastructure/Atomic.js"; 19 19 import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js"; 20 - import { MaybeLogger } from "../common/logging.js"; 20 + import { MaybeLogger } from '../common/MaybeLogger.js'; 21 21 import { 22 22 chromePlayerStateToReported, 23 23 genDeviceId,
-1
src/backend/sources/PlayerState/RealtimePlayer.ts
··· 1 - import { childLogger, Logger } from "@foxxmd/logging"; 2 1 import dayjs, { Dayjs } from "dayjs"; 3 2 import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; 4 3
+2 -1
src/backend/tests/listenbrainz/listenbrainz.test.ts
··· 6 6 import { PlayObject } from "../../../core/Atomic.js"; 7 7 import { UpstreamError } from "../../common/errors/UpstreamError.js"; 8 8 9 - import { ListenbrainzApiClient, playToListenPayload, listenResponseToPlay, listenPayloadToPlay } from "../../common/vendor/ListenbrainzApiClient.js"; 9 + import { ListenbrainzApiClient, listenResponseToPlay, listenPayloadToPlay } from "../../common/vendor/ListenbrainzApiClient.js"; 10 + import { playToListenPayload } from '../../common/vendor/listenbrainz/lzUtils.js'; 10 11 import { ListenPayload, ListenResponse, SubmitPayload } from '../../common/vendor/listenbrainz/interfaces.js'; 11 12 import { ExpectedResults } from "../utils/interfaces.js"; 12 13 import { withRequestInterception } from "../utils/networking.js";
-3
src/backend/tests/utils/playComparisons.test.ts
··· 1 - import { loggerTest } from "@foxxmd/logging"; 2 1 import { assert, expect } from 'chai'; 3 2 import clone from "clone"; 4 3 import { describe, it } from 'mocha'; 5 4 import { genericSourcePlayMatch, playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js"; 6 5 import { generatePlay, generatePlays } from "../../../core/PlayTestUtils.js"; 7 6 import { PlayObject } from "../../../core/Atomic.js"; 8 - 9 - const logger = loggerTest; 10 7 11 8 const newPlay = generatePlay(); 12 9
+1 -2
src/backend/utils/AsyncUtils.ts
··· 1 - import { randomInt } from "crypto"; 2 1 import pMap, { Mapper, Options, pMapIterable } from "p-map"; 3 2 import { sleep } from "../utils.js"; 4 3 ··· 61 60 sleep(initialStagger); 62 61 initialStagger += initialInterval; 63 62 } else { 64 - const s = randomInt(maxRandomStagger) 63 + const s = Math.max((Math.random() * 1000), maxRandomStagger) 65 64 await sleep(s); 66 65 } 67 66 return await mapper(x, index);
+1 -1
src/backend/utils/DataUtils.ts
··· 1 1 import JSON5 from "json5"; 2 2 import { constants, promises } from "fs"; 3 - import { MaybeLogger } from "../common/logging.js"; 3 + import { MaybeLogger } from '../common/MaybeLogger.js'; 4 4 5 5 export const asArray = <T>(data: T | T[]): T[] => { 6 6 if (Array.isArray(data)) {
+3 -2
src/backend/utils/ListenFetchUtils.ts
··· 1 - import { childLogger, Logger, loggerTest } from "@foxxmd/logging"; 1 + import { childLogger, Logger } from "@foxxmd/logging"; 2 2 import dayjs, { Dayjs } from "dayjs"; 3 3 import { Duration } from "dayjs/plugin/duration.js"; 4 4 import { PlayObject, UnixTimestamp } from "../../core/Atomic.js"; 5 5 import { CursorType, hasPagelessTimeRangeListens, hasPaginatedTimeRangeListens, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult, PaginatedListensTimeRangeOptions, PaginatedTimeRangeCommonOptions, PaginatedTimeRangeListens, PaginatedTimeRangeListensResult, PaginatedTimeRangeOptions, PaginatedTimeRangeSource, REFRESH_STALE_DEFAULT, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js"; 6 - import { loggerNoop, MaybeLogger } from "../common/logging.js"; 6 + import { loggerNoop } from '../common/MaybeLogger.js'; 7 + import { MaybeLogger } from '../common/MaybeLogger.js'; 7 8 import { sortByOldestPlayDate } from "../utils.js"; 8 9 import { sortByNewestPlayDate } from '../../core/PlayUtils.js'; 9 10 import { todayAwareFormat } from "../../core/TimeUtils.js";
+1 -1
src/backend/utils/MDNSUtils.ts
··· 2 2 import { Logger } from "@foxxmd/logging"; 3 3 import AvahiBrowser from 'avahi-browse'; 4 4 import { MdnsDeviceInfo } from "../common/infrastructure/Atomic.js"; 5 - import { MaybeLogger } from "../common/logging.js"; 5 + import { MaybeLogger } from '../common/MaybeLogger.js'; 6 6 import { sleep } from "../utils.js"; 7 7 import { debounce, DebouncedFunction } from "./debounce.js"; 8 8
+1 -1
src/backend/utils/PlayComparisonUtils.ts
··· 9 9 import { Duration } from "dayjs/plugin/duration.js"; 10 10 import { PlayTransformRules, TRANSFORM_HOOK, TransformHook } from "../common/infrastructure/Transform.js"; 11 11 import { Logger } from "@foxxmd/logging"; 12 - import { loggerNoop } from "../common/logging.js"; 12 + import { loggerNoop } from '../common/MaybeLogger.js'; 13 13 import { lifecyclelessInvariantTransform } from "../../core/PlayUtils.js"; 14 14 import { findAsyncSequential } from "./AsyncUtils.js"; 15 15
+1 -1
src/backend/utils/PlayTransformUtils.ts
··· 1 - import { Logger, loggerTest } from "@foxxmd/logging"; 1 + import { Logger } from "@foxxmd/logging"; 2 2 import { searchAndReplace as searchAndReplaceFunc, testMaybeRegex as testMaybeRegexFunc } from "@foxxmd/regex-buddy-core"; 3 3 import { PlayLifecycle, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 4 4
+1 -1
src/backend/utils/RequestUtils.ts
··· 7 7 import pRetry, { RetryContext, Options } from 'p-retry'; 8 8 import { DEFAULT_RETRY_MULTIPLIER } from "../common/infrastructure/Atomic.js"; 9 9 import { SimpleError } from "../common/errors/MSErrors.js"; 10 - import { loggerNoop } from "../common/logging.js"; 10 + import { loggerNoop } from '../common/MaybeLogger.js'; 11 11 import { findCauseByFunc } from "./ErrorUtils.js"; 12 12 import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js"; 13 13 import { isNodeNetworkException, NodeNetworkException } from "../common/errors/NodeErrors.js";
+1 -1
src/backend/utils/SchemaCompiledUtils.ts
··· 2 2 import { constants, promises } from "fs"; 3 3 import { resolve } from "path"; 4 4 import { projectDir } from "../common/index.js"; 5 - import { MaybeLogger } from "../common/logging.js"; 5 + import { MaybeLogger } from '../common/MaybeLogger.js'; 6 6 7 7 export const getSchemaForType = (type: string, logger: MaybeLogger = new MaybeLogger()): any => { 8 8 const path = resolve(projectDir, `src/backend/common/schema/${type}.json`);
+1 -1
src/backend/utils/SchemaUtils.ts
··· 9 9 createParser, 10 10 SchemaGenerator, LiteralType, LiteralTypeFormatter 11 11 } from "ts-json-schema-generator"; 12 - import { MaybeLogger } from "../common/logging.js"; 12 + import { MaybeLogger } from '../common/MaybeLogger.js'; 13 13 14 14 // https://github.com/vega/ts-json-schema-generator/issues/1899#issuecomment-2407674526 15 15 // https://github.com/vega/ts-json-schema-generator?tab=readme-ov-file#custom-formatting
+9
src/backend/version.ts
··· 1 + import { getVersion } from "@foxxmd/get-version"; 2 + 3 + export let version: string = 'unknown'; 4 + 5 + export const parseVersion = async () => { 6 + version = await getVersion({ priority: ['env', 'git', 'file'] }); 7 + return version; 8 + }; 9 +
+1
src/client/components/ActivityDetail.tsx
··· 28 28 } 29 29 } = props; 30 30 31 + debugger; 31 32 const [collapsibleOpen, setCollapsibleOpen] = useState(undefined); 32 33 33 34 return (
+2 -2
src/client/components/PlayData.tsx
··· 1 1 import React, { Fragment, useMemo, useState } from 'react'; 2 2 import { EmptyState, DataList, HStack, Tag, Tabs, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator, IconButton, Container, SimpleGrid, Float } from "@chakra-ui/react" 3 3 import { LuCode, LuText } from "react-icons/lu" 4 - import { JsonPlayObject } from '../../core/Atomic.js'; 4 + import { JsonPlayObject, PlayObjectLifecycleless } from '../../core/Atomic.js'; 5 5 import { shortTodayAwareFormat } from '../../core/TimeUtils.js'; 6 6 import dayjs from 'dayjs'; 7 7 import { ChakraCodeBlock } from './CodeBlock.js'; ··· 23 23 export type DisplayDates = false | 'all' | 'played' | 'seen'; 24 24 25 25 export interface PlayInfoProps { 26 - play?: JsonPlayObject 26 + play?: JsonPlayObject | PlayObjectLifecycleless<string> 27 27 final?: JsonPlayObject 28 28 showCodeToggle?: boolean 29 29 showCompare?: boolean
+1 -1
src/client/components/ScrobbleMatchResult.tsx
··· 11 11 import { formatNumber, jdiff } from "../../core/DataUtils"; 12 12 13 13 export interface ScrobbleMatchResultProps { 14 - match: PlayMatchResult 14 + match: PlayMatchResult<string> 15 15 } 16 16 17 17 export const ScrobbleMatchResult = (props: ScrobbleMatchResultProps) => {
+5
src/client/components/TransformSteps.tsx
··· 23 23 } = props; 24 24 25 25 let currentPlay: JsonPlayObject | false = JSON.parse(JSON.stringify(original)); 26 + if(currentPlay !== false) { 27 + currentPlay.data.meta = { 28 + ...(currentPlay.data.meta ?? {}), 29 + } 30 + } 26 31 27 32 return ( 28 33 <Timeline.Root variant="subtle" css={{ "--timeline-separator-display": 'block' }}>
+5 -2
src/core/tests/utils/fixtures.ts
··· 10 10 import clone from 'clone'; 11 11 import { jdiff } from '../../DataUtils.js'; 12 12 import { existingScrobble } from '../../../backend/utils/PlayComparisonUtils.js'; 13 - import { playToListenPayload } from '../../../backend/common/vendor/ListenbrainzApiClient.js'; 14 13 import { UpstreamError } from '../../../backend/common/errors/UpstreamError.js'; 14 + import { playToListenPayload } from '../../../backend/common/vendor/listenbrainz/lzUtils.js'; 15 15 16 16 interface BlockPath { key: string, parent: string }; 17 17 type BlockPaths = BlockPath[]; ··· 204 204 205 205 const { 206 206 name = ['preCompare', 'postCompare'][faker.number.int({ min: 0, max: 1 })], 207 - source = `${play.meta.source}-${faker.word.noun()}`, 207 + source = `${play.meta?.source}-${faker.word.noun()}`, 208 208 equal = faker.datatype.boolean(0.1), 209 209 inputCount 210 210 } = opts; ··· 235 235 new Traverse(modifiedPlay).forEach((ctx, x) => { 236 236 if (modifiableKeys.includes(ctx.key)) { 237 237 if (faker.datatype.boolean(0.3)) { 238 + if(ctx.key === 'meta' && (ctx.parent === undefined || ctx.parent.key !== 'data')) { 239 + return; 240 + } 238 241 somethingModified = true; 239 242 if(ctx.key === 'brainz' && Object.keys(x).length === 0) { 240 243 ctx.update(generateBrainz(play, {include: ['album', 'artist', 'track']}), true);
+18 -4
src/stories/List.stories.tsx
··· 8 8 import { generateJsonPlays } from "../core/PlayTestUtils.js"; 9 9 import { ErrorLike, JsonPlayObject } from "../core/Atomic.js"; 10 10 import {examplePlay, lastfmErrorExample} from './storyUtils.js'; 11 + import {playWithLifecycleScrobble, generatePlayWithLifecycle} from '../core/tests/utils/fixtures' 11 12 12 13 const stack = "Scrobble Submit Error: Failed to submit to Listenbrainz (listen_type single)\n at ListenbrainzApiClient.submitListen (/app/src/backend/common/vendor/ListenbrainzApiClient.ts:246:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async ListenbrainzScrobbler.doScrobble (/app/src/backend/scrobblers/ListenbrainzScrobbler.ts:87:28)\n at async ListenbrainzScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:679:28)\n at async ListenbrainzScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:920:39)\n at async ListenbrainzScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:894:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)"; 13 14 ··· 39 40 // More on argTypes: https://storybook.js.org/docs/api/argtypes 40 41 args: { 41 42 data:[ 42 - ...generateJsonPlays(2).map((x) => ({play: x, status: 'queued'})), 43 - {play: examplePlay(), status: 'scrobbled'}, 44 - {play: lastfmErrorExample(), status: 'error'} 45 - ] , 43 + // ...generateJsonPlays(2).map((x) => ({play: x, status: 'queued'})), 44 + // {play: examplePlay(), status: 'scrobbled'}, 45 + // {play: lastfmErrorExample(), status: 'error'} 46 + ] , 46 47 }, 48 + render: function Render(args, { loaded: { data } }) { return (<CList {...args} data={data ?? []}/>) }, 47 49 decorators: [ 48 50 (Story) => (<Provider><Container maxWidth="4xl"><Story/></Container></Provider>), 49 51 ] ··· 52 54 53 55 // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 54 56 export const List = meta.story({ 57 + loaders: [ 58 + async () => { 59 + const queued = await generatePlayWithLifecycle(); 60 + const scrobbled = await playWithLifecycleScrobble(generatePlayWithLifecycle()); 61 + const scrobbleError = await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true}); 62 + return {data: [ 63 + {play: queued, status: 'queued'}, 64 + {play: scrobbled, status: 'scrobbled'}, 65 + {play: scrobbleError, status: 'error'} 66 + ]}; 67 + } 68 + ], 55 69 //render: function Render(args) { return (<ChakraProvider><MyList></MyList></ChakraProvider>) } 56 70 });