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

Merge pull request #508 from FoxxMD/cacheAusterity

feat: Reduce cache memory usage

authored by

Matt Foxx and committed by
GitHub
(Mar 31, 2026, 9:01 AM EDT) 664395a0 034cfe7a

+287 -77
+1 -1
src/backend/common/AbstractComponent.ts
··· 312 312 } 313 313 314 314 if(cacheOk) { 315 - await this.cache.cacheTransform.set<LifecycleStep[]>(transformHash, steps, '10m'); 315 + await this.cache.cacheTransform.set<LifecycleStep[]>(transformHash, steps, '2m'); 316 316 } 317 317 318 318 return transformedPlay;
+233 -51
src/backend/common/Cache.ts
··· 1 - import { Cacheable, createKeyv, Keyv, KeyvStoreAdapter, KeyvOptions, CacheableOptions } from 'cacheable'; 1 + import { Cacheable, createKeyv, Keyv, KeyvStoreAdapter, KeyvOptions, CacheableOptions, KeyvCacheableMemory } from 'cacheable'; 2 2 import { FlatCache, FlatCacheOptions } from 'flat-cache'; 3 - import KeyvValkey from '@keyv/valkey'; 3 + import KeyvValkey, { KeyvValkeyOptions } from '@keyv/valkey'; 4 4 import dayjs, { Dayjs } from 'dayjs'; 5 5 import duration from 'dayjs/plugin/duration.js'; 6 6 import isBetween from 'dayjs/plugin/isBetween.js'; ··· 14 14 import path from 'path'; 15 15 import { cacheFunctions } from "@foxxmd/regex-buddy-core"; 16 16 import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.js'; 17 - import { asCacheAuthProvider, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js'; 17 + import { asCacheAuthProvider, asCacheConfig, 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 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 + import prom, { Gauge } from 'prom-client'; 23 24 24 25 dayjs.extend(utc) 25 26 dayjs.extend(isBetween); ··· 51 52 regexCache: ReturnType<typeof cacheFunctions>; 52 53 cacheTransform: Cacheable; 53 54 cacheClientScrobbles: Cacheable; 55 + cacheApi: Cacheable; 56 + hasInit: boolean = false; 54 57 55 58 logger: Logger; 56 59 60 + cacheHits: Gauge; 61 + cacheMisses: Gauge; 62 + cacheSets: Gauge; 63 + cacheCount: Gauge; 64 + //cacheVSize: Gauge; 65 + 57 66 constructor(logger: Logger, config: CacheConfigOptions = {}) { 58 67 this.logger = childLogger(logger, 'Cache'); 59 68 60 69 const { 61 70 metadata: { 62 - provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadataProvider | undefined) ?? 'memory'), 71 + provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadataProvider | undefined) ?? false), 63 72 connection: mConn = process.env.CACHE_METADATA_CONN, 64 73 ...restMetadata 65 74 } = {}, ··· 96 105 }; 97 106 98 107 this.regexCache = cacheFunctions(this.config.regex); 99 - this.cacheTransform = new Cacheable({primary: initMemoryCache({lruSize: 500})}); 100 - this.cacheClientScrobbles = new Cacheable({primary: initMemoryCache({lruSize: 100, ttl: '5m'})}); 108 + 109 + // for testing we default to in memory 110 + const inMemory = new Cacheable({primary: initMemoryCache({lruSize: 500, ttl: '1m'})}); 111 + this.cacheTransform = inMemory; 112 + this.cacheClientScrobbles = inMemory; 113 + this.cacheMetadata = inMemory; 114 + this.cacheAuth = inMemory; 115 + this.cacheScrobble = inMemory; 116 + this.cacheApi = inMemory; 101 117 } 102 118 103 - init = async () => { 119 + init = async (enableCollectors: boolean = false) => { 104 120 await this.initMetadataCache(); 105 121 await this.initScrobbleCache(); 106 122 await this.initAuthCache(); 107 - //this.cacheTransform = await this.initCacheable({provider: false, memory: {lruSize: 500}}, 'transform'); 123 + 124 + if(enableCollectors) { 125 + this.enableCollectors(); 126 + } 108 127 } 109 128 110 - protected initCacheable = async (config: CacheConfig, cacheFor: string) => { 129 + protected enableCollectors = () => { 130 + 131 + const collectors: {cache: Cacheable, name: string}[] = [ 132 + { cache: this.cacheMetadata, name: 'metadata' }, 133 + { cache: this.cacheScrobble, name: 'queued_scrobbles' }, 134 + { cache: this.cacheTransform, name: 'transformer' }, 135 + { cache: this.cacheClientScrobbles, name: 'historical_scrobbles' }, 136 + { cache: this.cacheApi, name: 'external_apis' } 137 + ]; 138 + 139 + this.cacheHits = new prom.Gauge({ 140 + name: 'multiscrobbler_cache_hits', 141 + help: 'cache hits', 142 + labelNames: ['cacheType', 'tier'], 143 + collect() { 144 + for(const set of collectors) { 145 + const [primary, secondary] = getStat(set.cache, 'hits'); 146 + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); 147 + if(secondary !== undefined) { 148 + this.labels({cacheType: set.name, tier: 'secondary'}).set(secondary); 149 + } 150 + } 151 + 152 + } 153 + }); 154 + this.cacheMisses = new prom.Gauge({ 155 + name: 'multiscrobbler_cache_misses', 156 + help: 'cache misses', 157 + labelNames: ['cacheType', 'tier'], 158 + collect() { 159 + for(const set of collectors) { 160 + const [primary, secondary] = getStat(set.cache, 'misses'); 161 + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); 162 + if(secondary !== undefined) { 163 + this.labels({cacheType: set.name, tier: 'secondary'}).set(secondary); 164 + } 165 + } 166 + 167 + } 168 + }); 169 + 170 + this.cacheMisses = new prom.Gauge({ 171 + name: 'multiscrobbler_cache_sets', 172 + help: 'cache sets', 173 + labelNames: ['cacheType', 'tier'], 174 + collect() { 175 + for(const set of collectors) { 176 + const [primary, secondary] = getStat(set.cache, 'sets'); 177 + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); 178 + if(secondary !== undefined) { 179 + this.labels({cacheType: set.name, tier: 'secondary'}).set(secondary); 180 + } 181 + } 182 + 183 + } 184 + }); 185 + 186 + this.cacheCount = new prom.Gauge({ 187 + name: 'multiscrobbler_cache_count', 188 + help: 'number of keys in cache', 189 + labelNames: ['cacheType', 'tier'], 190 + collect() { 191 + for(const set of collectors) { 192 + const [primary] = getStat(set.cache, 'count', false); 193 + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); 194 + } 195 + } 196 + }); 197 + 198 + // this.cacheVSize = new prom.Gauge({ 199 + // name: 'multiscrobbler_cache_vsize', 200 + // help: 'estimated byte size of values in cache', 201 + // labelNames: ['cacheType', 'tier'], 202 + // collect() { 203 + // for(const set of collectors) { 204 + // const [primary] = getStat(set.cache, 'vsize', false); 205 + // this.labels({cacheType: set.name, tier: 'primary'}).set(primary); 206 + // } 207 + // } 208 + // }); 209 + } 210 + 211 + protected initCacheable = async (cacheFor: string, primaryConfig: CacheConfig, secondaryConfig?: CacheConfig) => { 111 212 112 213 let logger = childLogger(this.logger, cacheFor); 113 - const providerHints = ['In-Memory (Primary)']; 114 - if(config.provider !== false) { 115 - providerHints.push(`${config.provider} (Secondary)`) 214 + const providerHints = []; 215 + if(primaryConfig.provider === false) { 216 + const cache = new Cacheable({primary: noopKeyv}); 217 + cache.stats.enabled = true; 218 + logger.verbose(`Cache Providers: Disabled`); 219 + return cache; 220 + } 221 + 222 + providerHints.push(`${primaryConfig.provider} (Primary)`) 223 + 224 + if(secondaryConfig === undefined || secondaryConfig.provider !== false) { 225 + providerHints.push(`Disabled (Secondary)`) 226 + } else { 227 + providerHints.push(`${secondaryConfig.provider} (Secondary)`); 116 228 } 117 - logger.verbose(`Cache Providers: ${providerHints.join(' | ')}`) 229 + logger.verbose(`Cache Providers: ${providerHints.join(' | ')}`); 118 230 119 231 const ns = `ms-${cacheFor.toLocaleLowerCase()}`; 120 232 121 233 const cacheOpts: CacheableOptions = { 122 - primary: initMemoryCache({ namespace: ns, lruSize: config.memory?.lruSize, ttl: config.memory?.ttl }) 234 + 123 235 } 124 236 125 - let secondaryCache: Keyv | KeyvStoreAdapter | undefined; 237 + try { 238 + cacheOpts.primary = await this.initCachableType(ns, primaryConfig, logger); 239 + } catch (e) { 240 + throw new Error('Could not init primary cache', {cause: e}); 241 + } 242 + 243 + if(secondaryConfig !== undefined && secondaryConfig.provider !== false) { 244 + try { 245 + cacheOpts.secondary = await this.initCachableType(ns, secondaryConfig, logger); 246 + } catch (e) { 247 + this.logger.warn(e); 248 + } 249 + } 250 + 251 + const cache = new Cacheable(cacheOpts); 252 + cache.stats.enabled = true; 253 + return cache; 254 + 255 + } 256 + 257 + protected initCachableType = async (namespace: string, config: CacheConfig, logger: Logger): Promise<Keyv<any> | KeyvStoreAdapter> => { 258 + 259 + if (config.provider === 'memory') { 260 + return initMemoryCache({ namespace, lruSize: config.lruSize, ttl: config.ttl }); 261 + } 126 262 127 263 if (config.provider === 'valkey') { 128 264 logger.debug(`Building valkey cache from ${config.connection}`); 129 265 try { 130 - secondaryCache = await initValkeyCache(ns, config.connection); 266 + const cache = await initValkeyCache(namespace, config.connection, undefined, {ttl: config.ttl}); 131 267 logger.debug('valkey cache connected'); 268 + return cache; 132 269 } catch (e) { 133 - this.logger.warn(e); 270 + throw e; 134 271 } 135 - } else if (config.provider === 'file') { 136 - logger.debug(`Building file cache from ${path.join(config.connection, `${ns}.cache`)}`); 272 + } 273 + if (config.provider === 'file') { 274 + logger.debug(`Building file cache from ${path.join(config.connection, `${namespace}.cache`)}`); 137 275 138 276 try { 139 - const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection, cacheId: `${ns}.cache` }, logger); 140 - secondaryCache = keyvFile; 277 + const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection, cacheId: `${namespace}.cache` }, {ttl: config.ttl}, logger); 278 + return keyvFile; 141 279 } catch (e) { 142 - logger.warn(e); 280 + throw e; 143 281 } 144 282 } 145 - 146 - if(secondaryCache !== undefined) { 147 - cacheOpts.secondary = secondaryCache; 148 - } 149 - return new Cacheable(cacheOpts); 150 - 151 283 } 152 284 153 285 initScrobbleCache = async () => { 154 - if (this.cacheScrobble === undefined) { 155 - if (!asCacheScrobbleProvider(this.config.scrobble.provider)) { 156 - throw new Error(`Cache Scrobble provider '${this.config.scrobble.provider}' must be one of: memory, valkey, file`); 286 + if (!this.hasInit) { 287 + let scrobbleConfig: CacheConfig | undefined; 288 + try { 289 + if(asCacheConfig(this.config.scrobble)) { 290 + scrobbleConfig = this.config.scrobble; 291 + this.cacheScrobble = await this.initCacheable('Scrobble', this.config.scrobble); 292 + } 293 + } catch (e) { 294 + this.logger.warn(new Error('Could not validate scrobble config! No fallback is possible', {cause: e})); 295 + this.cacheScrobble = await this.initCacheable('Scrobble', {provider: false}); 157 296 } 158 - 159 - this.cacheScrobble = await this.initCacheable(this.config.scrobble, 'Scrobble'); 160 297 } 161 298 } 162 299 163 300 initMetadataCache = async () => { 164 - if (this.cacheMetadata === undefined) { 165 - if (!asCacheMetadataProvider(this.config.metadata.provider)) { 166 - throw new Error(`Cache Metadata provider '${this.config.metadata.provider}' must be one of: memory, valkey`); 301 + if (!this.hasInit) { 302 + let metadataConfig: CacheConfig | undefined; 303 + try { 304 + if(asCacheConfig(this.config.metadata)) { 305 + metadataConfig = this.config.metadata; 306 + } 307 + } catch (e) { 308 + this.logger.warn(new Error('Could not validate metadata config, will fallback to memory cache only', {cause: e})); 167 309 } 168 - 169 - this.cacheMetadata = await this.initCacheable(this.config.metadata, 'Metadata'); 310 + this.cacheMetadata = await this.initCacheable('Metadata', {provider: 'memory', ttl: '3m', lruSize: 100}, metadataConfig === undefined ? undefined : {...this.config.metadata, ttl: '15m'}); 311 + this.cacheMetadata.stats.enabled = true; 312 + this.cacheClientScrobbles = await this.initCacheable('Historical Scrobbles', {provider: 'memory', ttl: '2m', lruSize: 50}, metadataConfig === undefined ? undefined : {...this.config.metadata, ttl: '10m'}); 313 + this.cacheClientScrobbles.stats.enabled = true; 314 + this.cacheTransform = await this.initCacheable('Transform Data', {provider: 'memory', ttl: '2m', lruSize: 100}, metadataConfig === undefined ? undefined : {...this.config.metadata, ttl: '5m'}); 315 + this.cacheTransform.stats.enabled = true; 316 + this.cacheApi = await this.initCacheable('External API Responses', {provider: 'memory', ttl: '30s', lruSize: 100}, metadataConfig === undefined ? undefined : {...this.config.metadata, ttl: '20m'}); 317 + this.cacheApi.stats.enabled = true; 170 318 } 171 319 } 172 320 173 321 initAuthCache = async () => { 174 - if (this.cacheAuth === undefined) { 175 - if (!asCacheAuthProvider(this.config.auth.provider)) { 176 - throw new Error(`Cache Auth provider '${this.config.auth.provider}' must be one of: memory, valkey, file`); 322 + if (!this.hasInit) { 323 + let authConfig: CacheConfig | undefined; 324 + try { 325 + if(asCacheConfig(this.config.scrobble)) { 326 + authConfig = this.config.auth; 327 + } 328 + } catch (e) { 329 + this.logger.warn(new Error('Could not validate auth config! will fallback to memory cache only', {cause: e})); 330 + this.cacheScrobble = await this.initCacheable('Scrobble', {provider: false}); 177 331 } 178 332 179 - this.cacheAuth = await this.initCacheable(this.config.auth, 'Auth'); 333 + this.cacheAuth = await this.initCacheable('Auth', {provider: 'memory', ttl: '3m'}, authConfig); 180 334 } 181 335 } 182 336 ··· 185 339 186 340 export const initMemoryCache = <T = any>(opts: Parameters<typeof createKeyv>[0] = {}): Keyv<T> | KeyvStoreAdapter => { 187 341 const { 188 - ttl = '1h', 342 + ttl = '60s', 189 343 lruSize = 200, 190 344 ...restOpts 191 345 } = opts; 192 346 const memory = createKeyv({ 193 347 ttl, 194 348 lruSize, 349 + // millisecond interval before checking for expired keys and deleting 350 + checkInterval: 10000, 195 351 ...restOpts, 196 352 useClone: false, 197 353 }); ··· 200 356 memory.serialize = (data) => { 201 357 return clone(data) as string; 202 358 } 359 + memory.stats.enabled = true; 203 360 return memory; 204 361 } 205 362 ··· 262 419 } 263 420 } 264 421 265 - export const initFileCache = async (opts: FlatCacheOptions = {}, logger: Logger = loggerNoop): Promise<[Keyv | KeyvStoreAdapter | undefined, FlatCache | undefined]> => { 422 + export const initFileCache = async (opts: FlatCacheOptions = {}, keyvOpts: KeyvOptions = {}, logger: Logger = loggerNoop): Promise<[Keyv | KeyvStoreAdapter | undefined, FlatCache | undefined]> => { 266 423 const flatCache = flatCacheCreate(opts); 267 424 try { 268 425 await flatCacheLoad(flatCache, logger); ··· 276 433 const cache = new Keyv({ 277 434 store: flatCache, 278 435 throwOnErrors: true, 279 - ...typesonMarshalling 436 + ...typesonMarshalling, 437 + ...keyvOpts 280 438 }); 439 + cache.stats.enabled = true; 281 440 return [cache, flatCache]; 282 441 } catch (e) { 283 442 throw e; 284 443 } 285 444 } 286 445 287 - export const valkeyCacheCreate = (ns: string, ...args: ConstructorParameters<typeof KeyvValkey>): Keyv => { 288 - const [connection, valkeyOpts = {}] = args; 446 + export const valkeyCacheCreate = (ns: string, connection: string, valkeyOpts: KeyvValkeyOptions = {}, keyvOpts: KeyvOptions = {}): Keyv => { 289 447 const valkey = new KeyvValkey(connection, { maxRetriesPerRequest: 5, connectTimeout: 1100, ...valkeyOpts }); 290 448 const kv = new Keyv({ 291 449 store: valkey, 292 450 throwOnErrors: true, 293 451 namespace: ns, 294 - ...typesonMarshalling 452 + ...typesonMarshalling, 453 + ...keyvOpts 295 454 }); 455 + kv.stats.enabled = true; 296 456 return kv; 297 457 } 298 458 299 - export const initValkeyCache = async (ns: string, ...args: ConstructorParameters<typeof KeyvValkey>): Promise<Keyv> => { 300 - const kv = valkeyCacheCreate(ns, ...args); 459 + export const initValkeyCache = async (ns: string, connection: string, valkeyOpts: KeyvValkeyOptions = {}, keyvOpts: KeyvOptions = {}): Promise<Keyv> => { 460 + const kv = valkeyCacheCreate(ns, connection, valkeyOpts, keyvOpts); 301 461 try { 302 462 await kv.get('test'); 303 463 return kv; 304 464 } catch (e) { 305 - throw new Error(`Unable to connect to cache ${args[0]}`, { cause: e }) 465 + throw new Error(`Unable to connect to cache ${connection}`, { cause: e }) 306 466 } 307 467 } 308 468 ··· 315 475 const data = typeson.parseSync(str); 316 476 return data; 317 477 } 478 + } 479 + 480 + const getStat = (cache: Cacheable, statName: string, getSecondary: boolean = true): [number, number?] => { 481 + let primary = cache.stats[statName]; 482 + if(statName === 'count' && cache.primary.store instanceof KeyvCacheableMemory) { 483 + primary = cache.primary.store.store.size; 484 + } 485 + let secondary: number; 486 + if(getSecondary && cache.secondary !== undefined) { 487 + secondary = cache.secondary.stats[statName]; 488 + } 489 + return [primary, secondary]; 490 + } 491 + 492 + const noopKeyv: KeyvStoreAdapter = { 493 + opts: {}, 494 + namespace: 'noop', 495 + get: (_) => undefined, 496 + set: (_, __, ___) => undefined, 497 + delete: (_) => undefined, 498 + clear: () => Promise.resolve(), 499 + on: (_, __) => undefined 318 500 }
+1 -1
src/backend/common/MaybeLogger.ts
··· 48 48 } 49 49 } 50 50 } 51 - export const noopLog = (_: any, ...rest: any) => undefined; 51 + const noopLog = (_: any, ...rest: any) => undefined; 52 52 53 53 export const loggerNoop: Logger = { 54 54 trace: noopLog,
+36 -4
src/backend/common/infrastructure/Atomic.ts
··· 8 8 import { MusicBrainzApi } from 'musicbrainz-api'; 9 9 import { SourceType } from './config/source/sources.js'; 10 10 import { ClientType, clientTypes } from './config/client/clients.js'; 11 + import assert, { AssertionError } from 'assert'; 12 + import { SimpleError } from '../errors/MSErrors.js'; 11 13 12 14 export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; 13 15 ··· 230 232 connection?: string; 231 233 [key: string]: any 232 234 } 235 + export interface CacheMemoryConfig extends CacheConfig<'memory'> { 236 + lruSize?: number 237 + ttl?: number 238 + } 239 + export interface CacheValkeyConfig extends CacheConfig<'valkey'> { 240 + } 241 + export interface CacheFileConfig extends CacheConfig<'file'> { 242 + } 243 + export interface CacheNoopConfig extends CacheConfig<false> { 244 + } 245 + 246 + export type CacheConfigType = CacheMemoryConfig | CacheValkeyConfig | CacheFileConfig | CacheNoopConfig; 247 + 233 248 export type CacheMetadataProvider = CacheProvider;//Exclude<CacheProvider, 'file'>; 234 - export type CacheMetadataConfig = CacheConfig<CacheMetadataProvider>; 249 + export type CacheMetadataConfig = CacheConfigType; 235 250 export const asCacheProvider = (val: boolean | string): val is CacheProvider => { 236 251 if(typeof val === 'string') { 237 252 return ['memory', 'valkey', 'file'].includes(val); 238 253 } 239 254 return val === false; 240 255 } 256 + export const asCacheEphemeralConfig = (val: Record<string, any>): val is (CacheMemoryConfig | CacheNoopConfig) => { 257 + return val.provider === false || val.provider === 'memory'; 258 + } 259 + export const asCacheConnectableConfig = (val: Record<string, any>): val is (CacheValkeyConfig | CacheFileConfig) => { 260 + if(!['valkey', 'file'].includes(val.provider)) { 261 + return false; 262 + } 263 + return typeof val.connection === 'string'; 264 + } 265 + export const asCacheConfig = (val: Record<string, any>): val is CacheConfigType => { 266 + if(!asCacheProvider(val.provider)) { 267 + assert(asCacheProvider(val.provider), `Cache provider must be one of: 'memory', 'valkey', 'file', or false`); 268 + } 269 + if(asCacheConnectableConfig(val) || asCacheEphemeralConfig(val)) { 270 + return true; 271 + } 272 + throw new SimpleError(`${val.provider} must have a connection property that is a string`); 273 + } 241 274 export const asCacheMetadataProvider = (val: any): val is CacheScrobbleProvider => asCacheProvider(val); 242 275 export type CacheScrobbleProvider = CacheProvider; 243 - export type CacheScrobbleConfig = CacheConfig<CacheScrobbleProvider>; 276 + export type CacheScrobbleConfig = CacheConfigType; 244 277 export const asCacheScrobbleProvider = (val: any): val is CacheScrobbleProvider => asCacheProvider(val); 245 278 246 279 export type CacheAuthProvider = CacheProvider; 247 - export type CacheAuthConfig = CacheConfig<CacheAuthProvider>; 280 + export type CacheAuthConfig = CacheConfigType; 248 281 export const asCacheAuthProvider = (val: any): val is CacheAuthProvider => asCacheProvider(val); 249 282 export interface CacheConfigOptions { 250 283 metadata?: CacheMetadataConfig; ··· 261 294 url?: string 262 295 rateLimit?: [number, number] 263 296 contact: string, 264 - ttl?: string 265 297 apiKey?: string 266 298 requestTimeout?: number 267 299 }
+5 -9
src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts
··· 45 45 escapeCharacters?: boolean 46 46 removeCharacters?: boolean, 47 47 using?: UsingTypes[] 48 - ttl?: string, 49 48 freetext?: boolean 50 49 } 51 50 ··· 61 60 super('Musicbrainz', name, config, options); 62 61 63 62 this.asyncStore = new AsyncLocalStorage(); 64 - this.cache = options.cache ?? getRoot().items.cache().cacheMetadata; 63 + this.cache = options.cache ?? getRoot().items.cache().cacheApi; 65 64 const mbMap = getRoot().items.mbMap(); 66 65 const mbApis: Record<string, MusicbrainzApiConfig> = {}; 67 66 for(const mbConfig of this.config.apis) { ··· 76 75 rateLimit: mbConfig.rateLimit ?? [1,1], 77 76 preRequest: options.logUrl === true || isDebugMode() ? (method, url, headers) => { 78 77 const cacheKey = this.asyncStore.getStore() ?? nanoid(); 79 - this.cache.set(`${cacheKey}-url`, `${method} - ${url}`, mbConfig.ttl ?? '1hr'); 78 + this.cache.set(`${cacheKey}-url`, `${method} - ${url}`); 80 79 if(mbConfig.apiKey !== undefined) { 81 80 headers.set('X-Api_key', mbConfig.apiKey); 82 81 } ··· 101 100 return 'API'; 102 101 } 103 102 104 - callApi = async <T = Response>(func: (mb: MusicBrainzApi) => Promise<any>, options?: { timeout?: number, ttl?: string, cacheKey?: string }): Promise<T> => { 103 + callApi = async <T = Response>(func: (mb: MusicBrainzApi) => Promise<any>, options?: { timeout?: number, cacheKey?: string }): Promise<T> => { 105 104 106 105 let apiConfig = this.rrApis.next().value; 107 106 108 107 const { 109 108 timeout = 30000, 110 - ttl = apiConfig.ttl ?? '1hr', 111 109 cacheKey 112 110 } = options || {}; 113 111 ··· 132 130 try { 133 131 const res = await this.callApiEndpoint(apiConfig.api, func, options); 134 132 if(cacheKey !== undefined) { 135 - await this.cache.set(cacheKey, res, ttl); 133 + await this.cache.set(cacheKey, res); 136 134 } 137 135 return res as T; 138 136 } catch (e) { ··· 202 200 escapeCharacters = true, 203 201 removeCharacters = false, 204 202 using = ['album','artist','title'], 205 - ttl, 206 203 freetext 207 204 } = options || {}; 208 205 ··· 311 308 312 309 313 310 this.logger.debug(`Search Query => ${q}`); 314 - this.cache.set(`${cacheKey}-qs`, q, '1hr'); 311 + this.cache.set(`${cacheKey}-qs`, q); 315 312 316 313 return mb.search('recording', { 317 314 query: q 318 315 }); 319 316 }, { 320 - ttl, 321 317 cacheKey 322 318 }); 323 319
+1 -1
src/backend/index.ts
··· 109 109 const scrobbleClients = new ScrobbleClients(root.get('clientEmitter'), root.get('sourceEmitter'), internalConfigOptional, root.get('logger')); 110 110 const scrobbleSources = new ScrobbleSources(root.get('sourceEmitter'), internalConfigOptional, root.get('logger')); 111 111 112 - await root.items.cache().init(); 112 + await root.items.cache().init(true); 113 113 114 114 initServer(logger, appLoggerStream, output, scrobbleSources, scrobbleClients); 115 115
+2 -2
src/backend/ioc.ts
··· 116 116 transformerManager.register({type: 'native', name: 'MSDefault'}); 117 117 } 118 118 119 - const metadataCache = (maybeSingletonCache !== undefined ? maybeSingletonCache : cacheFunc()).cacheMetadata; 120 - const coverArtApi = new CoverArtApiClient('', {}, {logger, cache: metadataCache}); 119 + const cacheApi = (maybeSingletonCache !== undefined ? maybeSingletonCache : cacheFunc()).cacheApi; 120 + const coverArtApi = new CoverArtApiClient('', {}, {logger, cache: cacheApi}); 121 121 122 122 const portVal: number | string = process.env.PORT ?? port; 123 123
-4
src/backend/tests/musicbrainz/musicbrainz.test.ts
··· 24 24 25 25 const defaultApiConfig: MusicbrainzApiConfigData = { 26 26 contact: 'contact@foxxmd.dev', 27 - ttl: '1ms' 28 27 }; 29 28 30 29 const createMbTransformer = (apis: MusicbrainzApiConfigData[] = [defaultApiConfig]) => { ··· 33 32 type: 'musicbrainz', 34 33 data: { 35 34 apis 36 - }, 37 - options: { 38 - ttl: '1ms' 39 35 } 40 36 }, { 41 37 logger: loggerTest,
+6 -2
src/backend/tests/scrobbler/scrobblers.test.ts
··· 22 22 import { DEFAULT_CONSOLIDATE_DURATION, DEFAULT_GROUP_DURATION, groupPlaysToTimeRanges } from '../../utils/ListenFetchUtils.js'; 23 23 import { asPlay } from '../../../core/tests/utils/fixtures.js'; 24 24 import { nanoid } from 'nanoid'; 25 + import { getRoot } from '../../ioc.js'; 26 + import { transientCache } from '../utils/CacheTestUtils.js'; 25 27 26 28 chai.use(asPromised); 27 29 ··· 511 513 512 514 afterEach(function () { 513 515 MockDate.reset(); 516 + const root = getRoot(); 517 + root.upsert({ cache: () => transientCache }); 514 518 }); 515 519 516 520 it('Calls timerange func to get SOT scrobbles when none exists', async function() { ··· 547 551 scrobbler.startScrobbling().then(() => null); 548 552 await emptied; 549 553 scrobbler.tryStopScrobbling().then(() => null); 550 - expect(sp.calledOnce).is.true; 554 + expect(sp.callCount).to.eq(1); 551 555 }); 552 556 553 557 it('Uses separate timerange calls when scrobbles are not closely grouped', async function() { ··· 567 571 scrobbler.startScrobbling().then(() => null); 568 572 await emptied; 569 573 scrobbler.tryStopScrobbling().then(() => null); 570 - expect(sp.calledTwice).is.true; 574 + expect(sp.callCount).to.eq(2); 571 575 }); 572 576 573 577 it('Gets fresh timerange if TTL of staleAfter has passed', async function() {
+1 -1
src/backend/tests/utils/CacheTestUtils.ts
··· 1 1 import { loggerTest } from "@foxxmd/logging"; 2 2 import { MSCache } from "../../common/Cache.js"; 3 3 4 - export const transientCache = () => new MSCache(loggerTest, {scrobble: {provider: 'memory'}, auth: {provider: 'memory'}}); 4 + export const transientCache = () => new MSCache(loggerTest, {scrobble: {provider: 'memory'}, auth: {provider: 'memory'}, metadata: {provider: 'memory'}});
+1 -1
src/backend/utils/PlayComparisonUtils.ts
··· 512 512 513 513 if (result.score <= score && score > 0) { 514 514 result.reason = confidence; 515 - result.closestMatchedPlay = x; 515 + result.closestMatchedPlay = lifecyclelessInvariantTransform(x); 516 516 result.match = score >= DUP_SCORE_THRESHOLD; 517 517 result.breakdowns = scoreBreakdowns; 518 518 result.score = score;