[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: Move abstract init into own abstract class

So that it can be used for transformer. Component (client/source) inherit from init class

FoxxMD (Nov 28, 2025, 11:17 PM UTC) 53e76631 c5269791

+308 -257
+11 -257
src/backend/common/AbstractComponent.ts
··· 5 5 import deepEqual from 'fast-deep-equal'; 6 6 import { Simulate } from "react-dom/test-utils"; 7 7 import { PlayObject } from "../../core/Atomic.js"; 8 - import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; 8 + import { buildTrackString } from "../../core/StringUtils.js"; 9 9 10 10 import { 11 - configPartsToStrongParts, countRegexes, 12 - isUserStage, 13 - transformPlayUsingParts 11 + configPartsToStrongParts, countRegexes, transformPlayUsingParts 14 12 } from "../utils/PlayTransformUtils.js"; 15 - import { hasNodeNetworkException } from "./errors/NodeErrors.js"; 16 - import { hasUpstreamError } from "./errors/UpstreamError.js"; 17 13 import { CommonClientConfig } from "./infrastructure/config/client/index.js"; 18 14 import { CommonSourceConfig } from "./infrastructure/config/source/index.js"; 19 - import play = Simulate.play; 20 - import { WebhookPayload } from "./infrastructure/config/health/webhooks.js"; 21 - import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, TransformRulesError } from "./errors/MSErrors.js"; 22 - import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; 15 + import { TransformRulesError } from "./errors/MSErrors.js"; 23 16 import { 24 - ConditionalSearchAndReplaceRegExp, 25 - PlayTransformParts, 26 - PlayTransformPartsArray, 17 + ConditionalSearchAndReplaceRegExp, PlayTransformPartsArray, 27 18 PlayTransformRules, 28 19 TRANSFORM_HOOK, 29 20 TransformHook 30 21 } from "./infrastructure/Transform.js"; 22 + import AbstractInitializable from "./AbstractInitializable.js"; 23 + import play = Simulate.play; 31 24 32 - export default abstract class AbstractComponent { 33 - requiresAuth: boolean = false; 34 - requiresAuthInteraction: boolean = false; 35 - authed: boolean = false; 36 - authFailure?: boolean; 25 + export default abstract class AbstractComponent extends AbstractInitializable { 37 26 38 - buildOK?: boolean | null; 39 - connectionOK?: boolean | null; 40 - cacheOK?: boolean | null; 41 - 42 - initializing: boolean = false; 43 - 44 - config: CommonClientConfig | CommonSourceConfig; 27 + declare config: CommonClientConfig | CommonSourceConfig; 45 28 46 29 transformRules: PlayTransformRules = {}; 47 30 regexCache!: ReturnType<typeof cacheFunctions>; 48 31 49 - logger: Logger; 50 - componentLogger?: Logger; 51 - 52 32 protected constructor(config: CommonClientConfig | CommonSourceConfig) { 53 - this.config = config; 33 + super(config); 54 34 } 55 35 56 - public abstract notify(payload: WebhookPayload): Promise<void>; 57 - 58 - protected abstract getIdentifier(): string; 59 - 60 - initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => { 61 - 62 - const {force = false, notify = false, notifyTitle = 'Init Error'} = options; 63 - 64 - this.logger.debug('Attempting to initialize...'); 36 + protected postCache(): Promise<void> { 65 37 try { 66 - this.initializing = true; 67 - if(this.componentLogger === undefined) { 68 - await this.buildComponentLogger(); 69 - } 70 - await this.buildInitData(force); 71 - await this.parseCache(force); 72 38 this.buildTransformRules(); 73 - await this.checkConnection(force); 74 - await this.testAuth(force); 75 - this.logger.info('Fully Initialized!'); 76 - try { 77 - await this.postInitialize(); 78 - } catch (e) { 79 - throw new PostInitError('Error occurred during post-initialization hook', {cause: e}); 80 - } 81 - return true; 82 - } catch(e) { 83 - if(notify) { 84 - await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'}); 85 - } 86 - throw new Error('Initialization failed', {cause: e}); 87 - } finally { 88 - this.initializing = false; 89 - } 90 - } 91 - 92 - private async buildComponentLogger() { 93 - await this.doBuildComponentLogger(); 94 - return; 95 - } 96 - 97 - protected async doBuildComponentLogger() { 98 - return; 99 - } 100 - 101 - tryInitialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => { 102 - if(this.initializing) { 103 - throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`) 104 - } 105 - try { 106 - return await this.initialize(options); 39 + return; 107 40 } catch (e) { 108 41 throw e; 109 42 } 110 43 } 111 44 112 - public async parseCache(force: boolean = false) { 113 - if(this.cacheOK) { 114 - if(!force) { 115 - return; 116 - } 117 - this.logger.debug('Cache OK but step was forced'); 118 - } 119 - try { 120 - const res = await this.doParseCache(); 121 - if(res === undefined) { 122 - this.cacheOK = null; 123 - this.logger.debug('No cache to parse.'); 124 - return; 125 - } 126 - if (res === true) { 127 - this.logger.verbose('Parsing caching succeeded'); 128 - } else if (typeof res === 'string') { 129 - this.logger.verbose(`Parsing caching succeeded => ${res}`); 130 - } 131 - this.cacheOK = true; 132 - } catch (e) { 133 - this.cacheOK = false; 134 - throw new ParseCacheError('Parsing cache for initialization failed', {cause: e}); 135 - } 136 - } 137 - 138 - /** 139 - * Build or parse any cache required for this Component 140 - * 141 - * * Return undefined if not possible or not required 142 - * * Return TRUE if build succeeded 143 - * * Return string if build succeeded and should log result 144 - * * Throw error on failure 145 - * */ 146 - protected async doParseCache(): Promise<true | string | undefined> { 147 - return; 148 - } 149 - 150 - 151 - public async buildInitData(force: boolean = false) { 152 - if(this.buildOK) { 153 - if(!force) { 154 - return; 155 - } 156 - this.logger.debug('Build OK but step was forced'); 157 - } 158 - try { 159 - const res = await this.doBuildInitData(); 160 - if(res === undefined) { 161 - this.buildOK = null; 162 - this.logger.debug('No required data to build.'); 163 - return; 164 - } 165 - if (res === true) { 166 - this.logger.verbose('Building required data init succeeded'); 167 - } else if (typeof res === 'string') { 168 - this.logger.verbose(`Building required data init succeeded => ${res}`); 169 - } 170 - this.buildOK = true; 171 - } catch (e) { 172 - this.buildOK = false; 173 - throw new BuildDataError('Building required data for initialization failed', {cause: e}); 174 - } 175 - } 176 - 177 - /** 178 - * Build any data/config/objects required for this Source to communicate with upstream service 179 - * 180 - * * Return undefined if not possible or not required 181 - * * Return TRUE if build succeeded 182 - * * Return string if build succeeded and should log result 183 - * * Throw error on failure 184 - * */ 185 - protected async doBuildInitData(): Promise<true | string | undefined> { 186 - return; 187 - } 188 - 189 45 public buildTransformRules() { 190 46 try { 191 47 this.doBuildTransformRules(); ··· 260 116 } 261 117 } 262 118 263 - public async checkConnection(force: boolean = false) { 264 - if(this.connectionOK) { 265 - if(!force) { 266 - return; 267 - } 268 - this.logger.debug('Connection OK but step was forced') 269 - } 270 - try { 271 - const res = await this.doCheckConnection(); 272 - if (res === undefined) { 273 - this.logger.debug('Connection check was not required.'); 274 - this.connectionOK = null; 275 - return; 276 - } else if (res === true) { 277 - this.logger.verbose('Connection check succeeded'); 278 - } else { 279 - this.logger.verbose(`Connection check succeeded => ${res}`); 280 - } 281 - this.connectionOK = true; 282 - } catch (e) { 283 - this.connectionOK = false; 284 - throw new ConnectionCheckError('Communicating with upstream service failed', {cause: e}); 285 - } 286 - } 287 - 288 - /** 289 - * Check Scrobbler upstream API/connection to ensure we can communicate 290 - * 291 - * * Return undefined if not possible or not required to check 292 - * * Return TRUE if communication succeeded 293 - * * Return string if communication succeeded and should log result 294 - * * Throw error if communication failed 295 - * */ 296 - protected async doCheckConnection(): Promise<true | string | undefined> { 297 - return; 298 - } 299 - 300 - authGated = () => this.requiresAuth && !this.authed 301 - 302 - canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true 303 - 304 - canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure); 305 - 306 - protected doAuthentication = async (): Promise<boolean> => this.authed 307 - 308 - // default init function, should be overridden if auth stage is required 309 - testAuth = async (force: boolean = false) => { 310 - if(!this.requiresAuth) { 311 - return; 312 - } 313 - if(this.authed) { 314 - if(!force) { 315 - return; 316 - } 317 - this.logger.debug('Auth OK but step was forced'); 318 - } 319 - 320 - if(this.authFailure) { 321 - if(!force) { 322 - if(this.requiresAuthInteraction) { 323 - throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication'); 324 - } 325 - throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated'); 326 - } 327 - this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced'); 328 - } 329 - 330 - try { 331 - this.authed = await this.doAuthentication(); 332 - this.authFailure = !this.authed; 333 - } catch (e) { 334 - // only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error 335 - this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false)); 336 - this.authed = false; 337 - throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e}); 338 - } 339 - } 340 - 341 - public isReady() { 342 - return (this.buildOK === null || this.buildOK === true) && 343 - (this.connectionOK === null || this.connectionOK === true) 344 - && !this.authGated(); 345 - } 346 - 347 - public isUsable() { 348 - return (this.buildOK === null || this.buildOK === true) && 349 - (this.connectionOK === null || this.connectionOK === true); 350 - } 351 - 352 - /** 353 - * Override to perform some action after successfully initializing 354 - * 355 - * Results will be try-catched and swallowed/logged if an error is thrown. This will not affect initialized state. 356 - * */ 357 - protected async postInitialize(): Promise<void> { 358 - return; 359 - } 360 - 361 119 public transformPlay = (play: PlayObject, hookType: TransformHook, log?: boolean) => { 362 120 363 121 let logger: Logger; ··· 421 179 getLogger().warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e})); 422 180 return play; 423 181 } 424 - } 425 - 426 - public additionalApiData(): Record<string, any> { 427 - return {}; 428 182 } 429 183 }
+291
src/backend/common/AbstractInitializable.ts
··· 1 + import { childLogger, Logger } from "@foxxmd/logging"; 2 + import { 3 + cacheFunctions, 4 + } from "@foxxmd/regex-buddy-core"; 5 + import deepEqual from 'fast-deep-equal'; 6 + import { Simulate } from "react-dom/test-utils"; 7 + import { PlayObject } from "../../core/Atomic.js"; 8 + import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; 9 + 10 + import { 11 + configPartsToStrongParts, countRegexes, 12 + isUserStage, 13 + transformPlayUsingParts 14 + } from "../utils/PlayTransformUtils.js"; 15 + import { hasNodeNetworkException } from "./errors/NodeErrors.js"; 16 + import { hasUpstreamError } from "./errors/UpstreamError.js"; 17 + import { CommonClientConfig } from "./infrastructure/config/client/index.js"; 18 + import { CommonSourceConfig } from "./infrastructure/config/source/index.js"; 19 + import play = Simulate.play; 20 + import { WebhookPayload } from "./infrastructure/config/health/webhooks.js"; 21 + import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError, TransformRulesError } from "./errors/MSErrors.js"; 22 + import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; 23 + 24 + export default abstract class AbstractInitializable { 25 + requiresAuth: boolean = false; 26 + requiresAuthInteraction: boolean = false; 27 + authed: boolean = false; 28 + authFailure?: boolean; 29 + 30 + buildOK?: boolean | null; 31 + connectionOK?: boolean | null; 32 + cacheOK?: boolean | null; 33 + 34 + initializing: boolean = false; 35 + 36 + config: CommonClientConfig | CommonSourceConfig; 37 + 38 + logger: Logger; 39 + componentLogger?: Logger; 40 + 41 + protected constructor(config: Record<string, any>) { 42 + this.config = config; 43 + } 44 + 45 + public abstract notify(payload: WebhookPayload): Promise<void>; 46 + 47 + protected abstract getIdentifier(): string; 48 + 49 + initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => { 50 + 51 + const {force = false, notify = false, notifyTitle = 'Init Error'} = options; 52 + 53 + this.logger.debug('Attempting to initialize...'); 54 + try { 55 + this.initializing = true; 56 + if(this.componentLogger === undefined) { 57 + await this.buildComponentLogger(); 58 + } 59 + await this.buildInitData(force); 60 + await this.parseCache(force); 61 + try { 62 + await this.postCache(); 63 + } catch (e) { 64 + if(e instanceof StageError) { 65 + throw e; 66 + } else { 67 + throw new Error('Error occurred during post-cache hook', {cause: e}); 68 + } 69 + } 70 + await this.checkConnection(force); 71 + await this.testAuth(force); 72 + this.logger.info('Fully Initialized!'); 73 + try { 74 + await this.postInitialize(); 75 + } catch (e) { 76 + throw new PostInitError('Error occurred during post-initialization hook', {cause: e}); 77 + } 78 + return true; 79 + } catch(e) { 80 + if(notify) { 81 + await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'}); 82 + } 83 + throw new Error('Initialization failed', {cause: e}); 84 + } finally { 85 + this.initializing = false; 86 + } 87 + } 88 + 89 + protected async buildComponentLogger() { 90 + await this.doBuildComponentLogger(); 91 + return; 92 + } 93 + 94 + protected async doBuildComponentLogger() { 95 + return; 96 + } 97 + 98 + tryInitialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => { 99 + if(this.initializing) { 100 + throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`) 101 + } 102 + try { 103 + return await this.initialize(options); 104 + } catch (e) { 105 + throw e; 106 + } 107 + } 108 + 109 + public async parseCache(force: boolean = false) { 110 + if(this.cacheOK) { 111 + if(!force) { 112 + return; 113 + } 114 + this.logger.debug('Cache OK but step was forced'); 115 + } 116 + try { 117 + const res = await this.doParseCache(); 118 + if(res === undefined) { 119 + this.cacheOK = null; 120 + this.logger.debug('No cache to parse.'); 121 + return; 122 + } 123 + if (res === true) { 124 + this.logger.verbose('Parsing caching succeeded'); 125 + } else if (typeof res === 'string') { 126 + this.logger.verbose(`Parsing caching succeeded => ${res}`); 127 + } 128 + this.cacheOK = true; 129 + } catch (e) { 130 + this.cacheOK = false; 131 + throw new ParseCacheError('Parsing cache for initialization failed', {cause: e}); 132 + } 133 + } 134 + 135 + /** 136 + * Build or parse any cache required for this Component 137 + * 138 + * * Return undefined if not possible or not required 139 + * * Return TRUE if build succeeded 140 + * * Return string if build succeeded and should log result 141 + * * Throw error on failure 142 + * */ 143 + protected async doParseCache(): Promise<true | string | undefined> { 144 + return; 145 + } 146 + 147 + 148 + protected async postCache(): Promise<void> { 149 + return; 150 + } 151 + 152 + public async buildInitData(force: boolean = false) { 153 + if(this.buildOK) { 154 + if(!force) { 155 + return; 156 + } 157 + this.logger.debug('Build OK but step was forced'); 158 + } 159 + try { 160 + const res = await this.doBuildInitData(); 161 + if(res === undefined) { 162 + this.buildOK = null; 163 + this.logger.debug('No required data to build.'); 164 + return; 165 + } 166 + if (res === true) { 167 + this.logger.verbose('Building required data init succeeded'); 168 + } else if (typeof res === 'string') { 169 + this.logger.verbose(`Building required data init succeeded => ${res}`); 170 + } 171 + this.buildOK = true; 172 + } catch (e) { 173 + this.buildOK = false; 174 + throw new BuildDataError('Building required data for initialization failed', {cause: e}); 175 + } 176 + } 177 + 178 + /** 179 + * Build any data/config/objects required for this Source to communicate with upstream service 180 + * 181 + * * Return undefined if not possible or not required 182 + * * Return TRUE if build succeeded 183 + * * Return string if build succeeded and should log result 184 + * * Throw error on failure 185 + * */ 186 + protected async doBuildInitData(): Promise<true | string | undefined> { 187 + return; 188 + } 189 + 190 + public async checkConnection(force: boolean = false) { 191 + if(this.connectionOK) { 192 + if(!force) { 193 + return; 194 + } 195 + this.logger.debug('Connection OK but step was forced') 196 + } 197 + try { 198 + const res = await this.doCheckConnection(); 199 + if (res === undefined) { 200 + this.logger.debug('Connection check was not required.'); 201 + this.connectionOK = null; 202 + return; 203 + } else if (res === true) { 204 + this.logger.verbose('Connection check succeeded'); 205 + } else { 206 + this.logger.verbose(`Connection check succeeded => ${res}`); 207 + } 208 + this.connectionOK = true; 209 + } catch (e) { 210 + this.connectionOK = false; 211 + throw new ConnectionCheckError('Communicating with upstream service failed', {cause: e}); 212 + } 213 + } 214 + 215 + /** 216 + * Check Scrobbler upstream API/connection to ensure we can communicate 217 + * 218 + * * Return undefined if not possible or not required to check 219 + * * Return TRUE if communication succeeded 220 + * * Return string if communication succeeded and should log result 221 + * * Throw error if communication failed 222 + * */ 223 + protected async doCheckConnection(): Promise<true | string | undefined> { 224 + return; 225 + } 226 + 227 + authGated = () => this.requiresAuth && !this.authed 228 + 229 + canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true 230 + 231 + canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure); 232 + 233 + protected doAuthentication = async (): Promise<boolean> => this.authed 234 + 235 + // default init function, should be overridden if auth stage is required 236 + testAuth = async (force: boolean = false) => { 237 + if(!this.requiresAuth) { 238 + return; 239 + } 240 + if(this.authed) { 241 + if(!force) { 242 + return; 243 + } 244 + this.logger.debug('Auth OK but step was forced'); 245 + } 246 + 247 + if(this.authFailure) { 248 + if(!force) { 249 + if(this.requiresAuthInteraction) { 250 + throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication'); 251 + } 252 + throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated'); 253 + } 254 + this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced'); 255 + } 256 + 257 + try { 258 + this.authed = await this.doAuthentication(); 259 + this.authFailure = !this.authed; 260 + } catch (e) { 261 + // only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error 262 + this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false)); 263 + this.authed = false; 264 + throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e}); 265 + } 266 + } 267 + 268 + public isReady() { 269 + return (this.buildOK === null || this.buildOK === true) && 270 + (this.connectionOK === null || this.connectionOK === true) 271 + && !this.authGated(); 272 + } 273 + 274 + public isUsable() { 275 + return (this.buildOK === null || this.buildOK === true) && 276 + (this.connectionOK === null || this.connectionOK === true); 277 + } 278 + 279 + /** 280 + * Override to perform some action after successfully initializing 281 + * 282 + * Results will be try-catched and swallowed/logged if an error is thrown. This will not affect initialized state. 283 + * */ 284 + protected async postInitialize(): Promise<void> { 285 + return; 286 + } 287 + 288 + public additionalApiData(): Record<string, any> { 289 + return {}; 290 + } 291 + }
+6
src/backend/common/transforms/AbstractTransformer.ts
··· 1 + import AbstractInitializable from "../AbstractInitializable.js"; 2 + 3 + 4 + export default abstract class AbstractTransformer extends AbstractInitializable { 5 + 6 + }