[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.

recfactor(transform): Use async hooks for logging to identify transform

Reduces logging noise

FoxxMD (Dec 3, 2025, 5:04 PM UTC) 1eab06c5 d217c91a

+57 -58
+4 -4
package-lock.json
··· 22 22 "@fortawesome/react-fontawesome": "^0.2.0", 23 23 "@foxxmd/chromecast-client": "^1.0.4", 24 24 "@foxxmd/get-version": "^0.0.3", 25 - "@foxxmd/logging": "^0.2.3", 25 + "@foxxmd/logging": "^0.2.4", 26 26 "@foxxmd/redact-string": "^0.1.2", 27 27 "@foxxmd/regex-buddy-core": "^0.1.2", 28 28 "@foxxmd/string-sameness": "^0.4.0", ··· 1634 1634 } 1635 1635 }, 1636 1636 "node_modules/@foxxmd/logging": { 1637 - "version": "0.2.3", 1638 - "resolved": "https://registry.npmjs.org/@foxxmd/logging/-/logging-0.2.3.tgz", 1639 - "integrity": "sha512-KMqWy42niMLMyz8YmQ09XVay5szhU2Y8lWzM3Fbm+zyfFRTiaYCSal5tQSeWmVdjps6LqtQNGDH4KG5a3Wgnrw==", 1637 + "version": "0.2.4", 1638 + "resolved": "https://registry.npmjs.org/@foxxmd/logging/-/logging-0.2.4.tgz", 1639 + "integrity": "sha512-pBqfs7OVtRtdmqyfF7eIRMGTpv8aOQwss7ROBt5HdF+0dnmmyz9HMcc9FHKSV2WzhV5K55+DTzPfrwJlFedFbg==", 1640 1640 "license": "MIT", 1641 1641 "dependencies": { 1642 1642 "pino": "^9.2.0",
+1 -1
package.json
··· 54 54 "@fortawesome/react-fontawesome": "^0.2.0", 55 55 "@foxxmd/chromecast-client": "^1.0.4", 56 56 "@foxxmd/get-version": "^0.0.3", 57 - "@foxxmd/logging": "^0.2.3", 57 + "@foxxmd/logging": "^0.2.4", 58 58 "@foxxmd/redact-string": "^0.1.2", 59 59 "@foxxmd/regex-buddy-core": "^0.1.2", 60 60 "@foxxmd/string-sameness": "^0.4.0",
+11 -9
src/backend/common/AbstractComponent.ts
··· 19 19 import play = Simulate.play; 20 20 import TransformerManager from "./transforms/TransformerManager.js"; 21 21 import { getRoot } from "../ioc.js"; 22 + import { nanoid } from "nanoid"; 22 23 23 24 export default abstract class AbstractComponent extends AbstractInitializable { 24 25 ··· 154 155 155 156 public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean) => { 156 157 157 - let logger: Logger; 158 - const labels = ['Play Transform', hookType]; 159 - const getLogger = () => logger !== undefined ? logger : childLogger(this.logger, labels); 158 + 159 + const asyncId = nanoid(6); 160 + let logger = childLogger(this.logger, ['Play Transform', hookType, asyncId]); 160 161 161 162 try { 162 163 let hook: StageConfig[]; ··· 180 181 return play; 181 182 } 182 183 184 + logger.debug(`Transform start for => ${buildTrackString(play)}`); 183 185 let transformedPlay: PlayObject = play; 184 186 let transformDetails: string[] = []; 185 187 for(const hookItem of hook) { ··· 193 195 let newTransformedPlay: PlayObject; 194 196 let err: Error; 195 197 try { 196 - newTransformedPlay = await this.transformManager.handleStage(hookItem, transformedPlay); 198 + newTransformedPlay = await this.transformManager.handleStage(hookItem, transformedPlay, asyncId); 197 199 } catch (e) { 198 200 err = e; 199 201 } 200 202 201 203 if(err !== undefined) { 202 204 if(onFailure === 'continue') { 203 - this.logger.warn(new Error('A transform encountered an error but continuing due to onFailure: continue', {cause: err})); 205 + logger.warn(new Error(`A transform encountered an error but continuing due to onFailure: continue`, {cause: err})); 204 206 } else { 205 - this.logger.error(new Error('Transform encountered an error', {cause: err})); 207 + logger.error(new Error(`Transform encountered an error`, {cause: err})); 206 208 if(!failureReturnPartial) { 207 209 // rewind to original play so we don't return partial transform 208 210 transformedPlay = play; ··· 218 220 transformedPlay = newTransformedPlay; 219 221 220 222 if(err === undefined && onSuccess === 'stop') { 221 - this.logger.debug('Stopping transform due to onSuccess: stop'); 223 + logger.debug(`${nanoid} Stopping transform due to onSuccess: stop`); 222 224 break; 223 225 } 224 226 } ··· 232 234 } else { 233 235 transformStatements.push(`=> ${transformDetails[transformDetails.length - 1]}`); 234 236 } 235 - this.logger.debug({labels: [...labels, hookType]}, `Transform Pipeline:\n${transformStatements.join('\n')}`); 237 + logger.debug({labels: [hookType]}, `Transform Pipeline:\n${transformStatements.join('\n')}`); 236 238 } 237 239 } 238 240 return transformedPlay; 239 241 } catch (e) { 240 - getLogger().warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e})); 242 + logger.warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e})); 241 243 return play; 242 244 } 243 245 }
+14 -12
src/backend/common/transforms/AbstractTransformer.ts
··· 1 1 import { childLogger, Logger } from "@foxxmd/logging"; 2 2 import { PlayObject, TransformerCommon, TransformerCommonConfig } from "../../../core/Atomic.js"; 3 - import { getRoot } from "../../ioc.js"; 4 3 import { isStageTyped, testWhenConditions } from "../../utils/PlayTransformUtils.js"; 5 4 import AbstractInitializable from "../AbstractInitializable.js"; 6 5 import { StageConfig } from "../infrastructure/Transform.js"; ··· 8 7 import { Cacheable } from "cacheable"; 9 8 import { hashObject } from "../../utils/StringUtils.js"; 10 9 import { playContentInvariantTransform } from "../../utils/PlayComparisonUtils.js"; 11 - import e from "express"; 12 10 import { isSimpleError } from "../errors/MSErrors.js"; 11 + import { capitalize } from "../../../core/StringUtils.js"; 13 12 14 13 export interface TransformerOptions { 15 14 logger: Logger ··· 38 37 public constructor(config: TransformerCommon, options: TransformerOptions) { 39 38 super(config); 40 39 this.name = config.name; 41 - this.logger = childLogger(options.logger, ['Transformer', this.config.type, this.config.name]); 42 40 this.transformType = config.type; 43 41 this.regex = options.regexCache ?? { searchAndReplace, testMaybeRegex, parseToRegexOrLiteralSearch }; 44 42 this.cache = options.cache; 45 - this.configHash = hashObject(this.config); 43 + this.configHash = hashObject(this.config); 44 + this.logger = childLogger(options.logger, [this.getIdentifier()]); 45 + } 46 + 47 + protected getIdentifier() { 48 + return `${capitalize(this.transformType)} - ${this.name}` 46 49 } 47 50 48 51 public parseConfig(data: any): Y { ··· 55 58 protected abstract doParseConfig(data: StageConfig): Y; 56 59 57 60 public async handle(data: Y, play: PlayObject): Promise<PlayObject> { 58 - 59 61 const cacheKey = `${this.configHash}-${hashObject(data)}-${hashObject(playContentInvariantTransform(play))}` 60 62 try { 61 63 const cachedTransform = await this.cache.get<PlayObject>(cacheKey); 62 64 if(cachedTransform !== undefined) { 63 - this.logger.debug('Cache hit'); 65 + this.logger.debug('Transform cache hit'); 64 66 return cachedTransform; 65 67 } 66 68 } catch (e) { 67 - this.logger.warn(new Error('Could not fetch cache key', {cause: e})); 69 + this.logger.warn(new Error(`Could not fetch cache key ${cacheKey}`, {cause: e})); 68 70 } 69 71 70 72 if (data.when !== undefined) { 71 73 if (!testWhenConditions(data.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { 72 - this.logger.debug('When condition not met, returning original Play'); 74 + this.logger.debug('Returning original Play because because when condition not met'); 73 75 await this.cache.set(cacheKey, play, '15s'); 74 76 return play; 75 77 } ··· 79 81 await this.checkShouldTransformPreData(play, data); 80 82 } catch (e) { 81 83 if(isSimpleError(e) && e.simple) { 82 - this.logger.debug(`preData check did not pass with reason: ${e.message}`); 84 + this.logger.debug(`Returning original Play because preData check did not pass: ${e.message}`); 83 85 } else { 84 - this.logger.debug(new Error('preData check did not pass', { cause: e })); 86 + this.logger.debug(new Error('Returning original Play because preData check did not pass', { cause: e })); 85 87 } 86 88 return play; 87 89 } ··· 97 99 await this.checkShouldTransform(play, transformData, data); 98 100 } catch (e) { 99 101 if(isSimpleError(e) && e.simple) { 100 - this.logger.debug(`postData check did not pass with reason: ${e.message}`); 102 + this.logger.debug(`Returning original Play because postData check did not pass: ${e.message}`); 101 103 } else { 102 - this.logger.debug(new Error('post check did not pass', { cause: e })); 104 + this.logger.debug(new Error('Returning original Play because post check did not pass', { cause: e })); 103 105 } 104 106 return play; 105 107 }
+12 -15
src/backend/common/transforms/MusicbrainzTransformer.ts
··· 1 1 import { DEFAULT_MISSING_TYPES, MissingMbidType, PlayObject, TrackMeta, TransformerCommon } from "../../../core/Atomic.js"; 2 2 import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.js"; 3 3 import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; 4 - import { ExternalMetadataTerm, PlayTransformNativeStage, PlayTransformMetadataStage, StageConfig } from "../infrastructure/Transform.js"; 4 + import { ExternalMetadataTerm, PlayTransformMetadataStage } from "../infrastructure/Transform.js"; 5 5 import AtomicPartsTransformer from "./AtomicPartsTransformer.js"; 6 - import { parseArtistCredits, parseTrackCredits, uniqueNormalizedStrArr } from "../../utils/StringUtils.js"; 7 - import { parseRegexSingle, parseToRegexOrLiteralSearch } from "@foxxmd/regex-buddy-core"; 8 6 import { TransformerOptions } from "./AbstractTransformer.js"; 9 - import { DELIMITERS_NO_AMP, MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../infrastructure/Atomic.js"; 10 - import { asArray } from "../../utils/DataUtils.js"; 7 + import { MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../infrastructure/Atomic.js"; 11 8 import { MaybeLogger } from "../logging.js"; 12 9 import { childLogger } from "@foxxmd/logging"; 13 10 import { MusicbrainzApiClient, MusicbrainzApiConfig, recordingToPlay } from "../vendor/musicbrainz/MusicbrainzApiClient.js"; ··· 15 12 import { getRoot, version } from "../../ioc.js"; 16 13 import { normalizeWebAddress } from "../../utils/NetworkUtils.js"; 17 14 import { intersect, missingMbidTypes } from "../../utils.js"; 18 - import { isSimpleError, SimpleError } from "../errors/MSErrors.js"; 19 - import { buildTrackString } from "../../../core/StringUtils.js"; 15 + import { SimpleError } from "../errors/MSErrors.js"; 20 16 21 17 export const asMissingMbid = (str: string): MissingMbidType => { 22 18 const clean = str.trim().toLocaleLowerCase(); ··· 115 111 } 116 112 117 113 this.api = new MusicbrainzApiClient(this.config.name, {apis: Object.values(mbApis)}, { 118 - logger: this.logger 114 + logger: this.logger, 119 115 }); 120 116 121 117 return true; ··· 158 154 159 155 const missing = missingMbidTypes(play); 160 156 if(intersect(searchWhenMissing, missing).length > 0) { 161 - this.logger.debug(`${buildTrackString(play)} - Missing desired MBIDs for ${missing.join(', ')}`); 157 + this.logger.debug(`Missing desired MBIDs for: ${missing.join(', ')}`); 162 158 } else if(forceSearch) { 163 - this.logger.debug(`${buildTrackString(play)} - No desired MBIDs are missing but forceSearch = true`); 159 + this.logger.debug(`No desired MBIDs are missing but forceSearch = true`); 164 160 } else { 165 161 throw new SimpleError('No desired MBIDs are missing'); 166 162 } ··· 170 166 171 167 // TODO maybe search more broadly if first query doesn't hit? 172 168 const results = await this.api.searchByRecording(play); 173 - this.logger.debug(`${buildTrackString(play)} Got results`); 174 169 175 170 if(results === undefined || results.recordings?.length === 0) { 171 + if(results === undefined) { 172 + this.logger.warn('results were unexpectedly undefined! API should have thrown...'); 173 + } 176 174 return undefined; 177 175 } 178 176 ··· 181 179 182 180 public async checkShouldTransform(play: PlayObject, transformData: MusicbrainzBestMatch | undefined, stageConfig: MusicbrainzTransformerDataStage): Promise<void> { 183 181 if(transformData === undefined) { 184 - throw new SimpleError('No match returned from Musicbrainz API'); 182 + throw new SimpleError('No matches returned from Musicbrainz API'); 185 183 } 186 184 187 185 const { ··· 192 190 this.logger.debug({bestMatch: transformData.play}, 'Best Match'); 193 191 throw new SimpleError(`Musicbrainz best match score of ${transformData.score} was less than minimum score of ${stageConfig.score}`); 194 192 } 193 + 194 + this.logger.debug(`Got valid match`); 195 195 } 196 196 197 197 protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, transformData: MusicbrainzBestMatch): Promise<string | undefined> { ··· 250 250 251 251 public notify(payload: WebhookPayload): Promise<void> { 252 252 return; 253 - } 254 - protected getIdentifier(): string { 255 - return 'Musicbrainz Transformer'; 256 253 } 257 254 258 255 }
-3
src/backend/common/transforms/NativeTransformer.ts
··· 234 234 public notify(payload: WebhookPayload): Promise<void> { 235 235 return; 236 236 } 237 - protected getIdentifier(): string { 238 - return 'Native Transformer'; 239 - } 240 237 241 238 }
+13 -9
src/backend/common/transforms/TransformerManager.ts
··· 7 7 import { isStageTyped } from "../../utils/PlayTransformUtils.js"; 8 8 import { MSCache } from "../Cache.js"; 9 9 import NativeTransformer from "./NativeTransformer.js"; 10 - import { MusicbrainzApiClient } from "../vendor/musicbrainz/MusicbrainzApiClient.js"; 11 - import { MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../infrastructure/Atomic.js"; 12 - import { normalizeWebAddress } from "../../utils/NetworkUtils.js"; 13 - import { MusicBrainzApi } from "musicbrainz-api"; 14 10 import MusicbrainzTransformer, { MusicbrainzTransformerConfig } from "./MusicbrainzTransformer.js"; 11 + import { AsyncLocalStorage } from 'node:async_hooks'; 12 + import { nanoid } from "nanoid"; 15 13 16 14 export default class TransformerManager { 17 15 ··· 19 17 protected parentLogger: Logger; 20 18 protected transformers: Map<string, AbstractTransformer[]> = new Map(); 21 19 protected cache: MSCache; 20 + protected asyncStore: AsyncLocalStorage<string>; 22 21 23 22 public constructor(logger: Logger, cache: MSCache) { 24 23 this.logger = childLogger(logger, 'Transformer Manager'); 25 24 this.parentLogger = logger; 26 25 this.cache = cache; 26 + this.asyncStore = new AsyncLocalStorage(); 27 27 } 28 28 29 29 public register(config: TransformerCommonConfig): void { ··· 41 41 42 42 this.logger.verbose(`Registering ${config.type} transformer with name '${tName}'`); 43 43 44 + const tLogger = childLogger(this.parentLogger, ['Transformer', () => this.asyncStore.getStore() ?? undefined]); 45 + 44 46 let t: AbstractTransformer; 45 47 switch (config.type) { 46 48 case 'user': 47 - t = new UserTransformer({ name: tName, ...config }, {logger: this.parentLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform}); 49 + t = new UserTransformer({ name: tName, ...config }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform}); 48 50 break; 49 51 case 'native': 50 - t = new NativeTransformer({ name: tName, ...config }, {logger: this.parentLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform}); 52 + t = new NativeTransformer({ name: tName, ...config }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform}); 51 53 break; 52 54 case 'musicbrainz': 53 - t = new MusicbrainzTransformer({ name: tName, ...config as MusicbrainzTransformerConfig }, {logger: this.parentLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform}); 55 + t = new MusicbrainzTransformer({ name: tName, ...config as MusicbrainzTransformerConfig }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform}); 54 56 break; 55 57 default: 56 58 throw new Error(`No transformer of type '${config.type}' exists.`); ··· 104 106 return t.parseConfig(data); 105 107 } 106 108 107 - public async handleStage(data: StageConfig, play: PlayObject): Promise<PlayObject> { 109 + public async handleStage(data: StageConfig, play: PlayObject, asyncId: string = nanoid(6)): Promise<PlayObject> { 108 110 const list = this.transformers.get(data.type); 109 111 if (list === undefined || list.length === 0) { 110 112 throw new Error(`No transformer of type '${data.type}' is registered.`); ··· 127 129 } 128 130 129 131 try { 130 - return await t.handle(data, play); 132 + return this.asyncStore.run(asyncId, async () => { 133 + return await t.handle(data, play); 134 + }); 131 135 } catch (e) { 132 136 throw new Error('Stage processing failed', {cause: e}); 133 137 }
-3
src/backend/common/transforms/UserTransformer.ts
··· 96 96 public notify(payload: WebhookPayload): Promise<void> { 97 97 return; 98 98 } 99 - protected getIdentifier(): string { 100 - return 'User Transformer'; 101 - } 102 99 103 100 }
+1 -1
src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts
··· 87 87 } 88 88 89 89 90 - this.logger.debug(`Starting search for ${buildTrackString(play)}`); 90 + this.logger.debug(`Starting search`); 91 91 const res = await this.callApi<IRecordingList>((mb) => { 92 92 const query: Record<string, any> = { 93 93 recording: play.data.track
+1 -1
src/backend/tests/ytm/ytm.test.ts
··· 211 211 const interimPlays = [generatePlay({duration: 40}, { comment: 'Today' }), generatePlay({duration: 200}, { comment: 'Today' })] 212 212 const prependedPlays = [firstPlay, ...interimPlays, ...plays]; 213 213 const prependResult = source.parseRecentAgainstResponse(prependedPlays); 214 - expect(prependResult.plays).length(2); 214 + expect(prependResult.plays).length(3); 215 215 expect(prependResult.plays[prependResult.plays.length - 1].data.track).eq(firstPlay.data.track) 216 216 }); 217 217 });