From 5be01724cd3c3d5947fcbb876d3dfeca87256fc2 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 23 Sep 2026 20:19:55 +0000 Subject: [PATCH] feat(transform): Implement CoverArtArchive transformer --- .../common/transforms/TransformerManager.ts | 6 + .../CoverArtArchiveTransformer.ts | 310 ++++++++++++++++++ .../CoverArtArchiveTransformerUtil.ts | 85 +++++ .../vendor/musicbrainz/CoverArtApiClient.ts | 74 ++--- .../vendor/musicbrainz/CoverArtApiTypes.ts | 110 +++++++ .../vendor/musicbrainz/CovertArtApiPool.ts | 74 +++++ src/backend/ioc.ts | 14 + .../tests/coverartarchive/caaFixtures.ts | 28 ++ .../coverartarchive/coverartarchive.test.ts | 227 +++++++++++++ .../tests/musicbrainz/musicbrainz.test.ts | 66 +--- src/core/Transform.ts | 8 +- src/core/tests/utils/PlayTestUtils.ts | 7 +- 12 files changed, 897 insertions(+), 112 deletions(-) create mode 100644 src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts create mode 100644 src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformerUtil.ts create mode 100644 src/backend/common/vendor/musicbrainz/CoverArtApiTypes.ts create mode 100644 src/backend/common/vendor/musicbrainz/CovertArtApiPool.ts create mode 100644 src/backend/tests/coverartarchive/caaFixtures.ts create mode 100644 src/backend/tests/coverartarchive/coverartarchive.test.ts diff --git a/src/backend/common/transforms/TransformerManager.ts b/src/backend/common/transforms/TransformerManager.ts index b27db14b..91ab6df7 100644 --- a/src/backend/common/transforms/TransformerManager.ts +++ b/src/backend/common/transforms/TransformerManager.ts @@ -11,6 +11,7 @@ import { nanoid } from "nanoid"; import { SimpleError, StageTransformError } from "../errors/MSErrors.ts"; import { configFromEnv as rsConfigFromEnv } from "./rocksky/RockskyTransformerUtil.ts"; import { type RockskyTransformerConfig } from "../vendor/rocksky/interfaces.ts"; +import type { CovertArtArchiveTransformerConfig } from "./coverartarchive/CoverArtArchiveTransformerUtil.ts"; export const DEFAULT_TRANSFORMER_NAME = 'MSDefault'; export default class TransformerManager { @@ -31,6 +32,7 @@ export default class TransformerManager { this.addTransformerConfig({type: 'user', name: DEFAULT_TRANSFORMER_NAME}); this.addTransformerConfig({type: 'native', name: DEFAULT_TRANSFORMER_NAME}); this.addTransformerConfig({type: 'rocksky', name: DEFAULT_TRANSFORMER_NAME}); + this.addTransformerConfig({type: 'coverartarchive', name: DEFAULT_TRANSFORMER_NAME}); } public addTransformerConfig(config: TransformerCommonConfig): void { @@ -96,6 +98,10 @@ export default class TransformerManager { const RockskyTransformer = (await import("./rocksky/RockskyTransformer.ts")).default; t = new RockskyTransformer({ name: tName, ...config as RockskyTransformerConfig }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform}); } break; + case 'coverartarchive': { + const CovertArtArchiveTransformer = (await import("./coverartarchive/CoverArtArchiveTransformer.ts")).default; + t = new CovertArtArchiveTransformer({ name: tName, ...config as CovertArtArchiveTransformerConfig }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform}); + } break; default: throw new Error(`No transformer of type '${config.type}' exists.`); } diff --git a/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts new file mode 100644 index 00000000..1e00b0a0 --- /dev/null +++ b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts @@ -0,0 +1,310 @@ +import { type ArtistCredit, type LifecycleInput, type OptionalCacheUsage, type PlayObject, type ArtMeta } from "../../../../core/Atomic.ts"; +import { isWhenCondition, testWhenConditions } from "../../../utils/PlayTransformUtils.ts"; +import type {WebhookPayload} from "../../infrastructure/config/health/webhooks.ts"; +import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../../core/Transform.ts"; +import AtomicPartsTransformer from "../AtomicPartsTransformer.ts"; +import type {TransformerOptions} from "../AbstractTransformer.ts"; +import { MaybeLogger } from '../../MaybeLogger.ts'; +import { childLogger } from "@foxxmd/logging"; +import { difference } from "../../../utils.ts"; +import { SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError } from "../../errors/MSErrors.ts"; +import type { Cacheable } from "cacheable"; +import { hasArtFields, type CAAMissingType, type CoverArtArchiveTransformData, type CovertArtArchiveTransformerConfig } from "./CoverArtArchiveTransformerUtil.ts"; +import { CoverArtClientPool } from "../../vendor/musicbrainz/CovertArtApiPool.ts"; +import { coverImageHas, type CoverArtReleaseResponse } from "../../vendor/musicbrainz/CoverArtApiTypes.ts"; + +export interface CoverArtArchiveTransformDataStrong extends CoverArtArchiveTransformData { +} + +export interface CoverArtArchiveTransformerDataStage extends CoverArtArchiveTransformDataStrong,PlayTransformMetadataStage { +} + +type MSCoverArtReleaseResponse = CoverArtReleaseResponse & {type?: 'album' | 'releaseGroup', requestQuery: string, lifecycleInputs?: LifecycleInput[]}; + +type ArtUriData = {uri: string, lifecycleInputs?: LifecycleInput[], type: 'album' | 'releaseGroup'} + +export const parseStageConfig = (data: CoverArtArchiveTransformData | undefined = {}, logger: MaybeLogger = new MaybeLogger()): CoverArtArchiveTransformDataStrong => { + + if (data === null || typeof data !== 'object') { + throw new Error('Musicbrainz Transformer data should be an object or not defined.'); + } + + const config: CoverArtArchiveTransformDataStrong = { + searchWhenMissing: true, + allowedTypes: ['front'], + forceSearch: false, + allowedSizes: ['any'], + ...data, + }; + + logger.debug(`Will search if missing: ${config.searchWhenMissing === true ? 'all' : config.searchWhenMissing.join(', ')}`); + + logger.debug(`Allowed image types: ${config.allowedTypes.join(',')}`); + + return config; +} + +export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { + + declare config: CovertArtArchiveTransformerConfig; + + protected defaults: CoverArtArchiveTransformDataStrong; + + protected api: CoverArtClientPool; + protected clientCache?: Cacheable; + + public constructor(config: CovertArtArchiveTransformerConfig, options: TransformerOptions & {clientCache?: Cacheable}) { + super(config, options); + this.clientCache = options.clientCache; + this.staggerOpts = { + initialInterval: 0, + maxRandomStagger: 100 + } + } + + protected async doBuildInitData(): Promise { + this.defaults = parseStageConfig(this.config.defaults, childLogger(this.logger, 'Defaults')); + + this.api = new CoverArtClientPool(this.config.name, this.config.data ?? {}, {logger: this.logger, cache: this.clientCache}); + + return true; + } + + protected doParseConfig(data: CoverArtArchiveTransformerDataStage) { + if (data.type !== 'coverartarchive') { + throw new Error(`CoverArtAchive Transformer is only usable with 'coverartarchive' type stages`); + } + + const stage: CoverArtArchiveTransformerDataStage = { + ...data, + ...parseStageConfig(data), + type: 'coverartarchive' + } + + for (const k of ['art']) { + if (!(k in stage)) { + stage[k] = true; + continue; + } + if (Array.isArray(stage[k])) { + throw new Error(`${k} must be a boolean or when object`); + } + if (typeof stage[k] === 'boolean') { + continue; + } + if (typeof stage[k] === 'object' && !isWhenCondition(stage[k])) { + throw new Error(`${k} is not a valid when object`); + } + } + return stage; + } + + public async handlePreFetch(play: PlayObject, stageConfig: CoverArtArchiveTransformerDataStage): Promise { + const { + searchWhenMissing = this.defaults.searchWhenMissing, + forceSearch = this.defaults.forceSearch + } = stageConfig; + + const found: CAAMissingType[] = hasArtFields(play); + if(searchWhenMissing === true) { + if(found.length !== 0) { + throw new SkipTransformStageError(`At least one art field (${found.join(',')}) already exists`, {shortStack: true}); + } + this.logger.debug('Play has no art fields'); + } else { + const missing = difference(searchWhenMissing, found); + if(missing.length > 0) { + this.logger.debug(`Play is missing desired fields: ${missing.join(', ')}`); + } else if(forceSearch) { + this.logger.debug(`All desired fields exist but forceSearch = true`); + } else { + throw new SkipTransformStageError(`No desired fields (${searchWhenMissing.join(',')}) are missing`, {shortStack: true}); + } + } + + } + + public async getTransformerData(play: PlayObject, stageConfig: CoverArtArchiveTransformerDataStage, opts?: OptionalCacheUsage): Promise { + + const { + allowedTypes = this.defaults.allowedTypes, + allowedSizes = this.defaults.allowedSizes, + } = stageConfig; + + let results: MSCoverArtReleaseResponse; + let resultType: 'album' | 'releaseGroup'; + const queries: LifecycleInput[] = []; + + for(const searchType of ['album','releaseGroup'] as const) { + try { + results = await this.searchByMbid(play, searchType, stageConfig, opts); + queries.push({type: `rsQuery-${searchType}${results.images === undefined ? '-empty' : ''}`, input: results.requestQuery}); + if(results.images !== undefined) { + if(allowedTypes.includes('any') && allowedSizes.includes('any')) { + resultType = searchType; + break; + } + const meetsRequirements = results.images.some(x => { + const hasFields = coverImageHas(x); + if(!allowedTypes.includes('any') && difference(allowedTypes, hasFields.types).length > 0) { + return false; + } + if(!allowedSizes.includes('any') && difference(allowedSizes, hasFields.sizes).length > 0) { + return false; + } + return true; + }); + if(meetsRequirements) { + resultType = searchType; + break; + } + results.images = []; + } + + continue; + } catch (e) { + if(e instanceof SearchPrerequisiteError) { + queries.push({type: `rsQuery-${searchType}-prereqFailure`, input: `Search type ${searchType} did not meet prerequesites: ${e.message}`}); + this.logger.debug(`Search type ${searchType} did not meet prerequesites: ${e.message}`); + } else { + // we should be catching any unrecoverable errors in api calls + // so we should only get here if something truly bad has happened + // and we probably don't want to try additional api calls + throw new StageTransformError('Search Error', 'Unexpected error occurred while getting CoverArtArchive song matches', {cause: e, inputs: queries}); + } + } + } + + // @ts-expect-error we check for results in postFetch + return {...(results ?? {requestQuery: undefined}), lifecycleInputs: queries, type: resultType}; + } + + public async searchByMbid(play: PlayObject, mbidType: 'album' | 'releaseGroup', stageConfig: CoverArtArchiveTransformerDataStage, opts: OptionalCacheUsage = {}): Promise { + const mbid = play.data.meta?.brainz?.[mbidType]; + if(mbid === undefined) { + throw new SearchPrerequisiteError(`Play does not have ${mbidType} MBID`); + } + + this.logger.debug({labels: ['MBID Search']},`Searching with ${mbidType} MBID`); + const requestQuery = mbid; + try { + const res = await this.api.proxy.getCovers(mbid, mbidType === 'album' ? 'release' : 'release-group'); + return { + requestQuery, + ...res + }; + } catch (e) { + e.requestQuery = requestQuery; + throw e; + } + } + + public async handlePostFetch(play: PlayObject, transformData: MSCoverArtReleaseResponse, stageConfig: CoverArtArchiveTransformerDataStage): Promise { + const { + allowedTypes = this.defaults.allowedTypes, + allowedSizes = this.defaults.allowedSizes, + preferredSizes = this.defaults.preferredSizes ?? ['250','500','1200'], + } = stageConfig; + + if(transformData.images === undefined) { + throw new StagePrerequisiteError('All search prerequisites failed, CoverArtArchive API could not be searched with the given Play', + { + shortStack: true, + inputs: transformData.lifecycleInputs + }); + } + if(transformData.images.length === 0) { + throw new StagePrerequisiteError('CoverArtArchive API returned results but none met configured requirements', + { + shortStack: true, + inputs: transformData.lifecycleInputs + }); + } + + const validImages = transformData.images.filter(x => { + const hasFields = coverImageHas(x); + if(!allowedTypes.includes('any') && difference(allowedTypes, hasFields.types).length > 0) { + return false; + } + if(!allowedSizes.includes('any') && difference(allowedSizes, hasFields.sizes).length > 0) { + return false; + } + return true; + }); + + let preferred: string; + for(const p of preferredSizes) { + for(const image of validImages) { + if(image.thumbnails[p] !== undefined) { + preferred = image.thumbnails[p]; + break; + } + } + } + if(preferred === undefined) { + // get the first thumb from the first image + preferred = Object.values(validImages[0].thumbnails)[0]; + } + + try { + const artUrl = await this.api.proxy.getCoverThumbFromUrl(preferred); + return {uri: artUrl, lifecycleInputs: transformData.lifecycleInputs, type: transformData.type} + } catch (e) { + throw new StageTransformError('Fetch Error', 'Unexpected error occurred while getting CoverArtArchive final url', {cause: e, inputs: transformData.lifecycleInputs}); + } + } + + protected async handleTitle(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { + return play.data.track; + } + protected async handleArtists(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { + return play.data.artists; + } + protected async handleAlbumArtists(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { + return play.data.albumArtists; + } + protected async handleAlbum(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { + return play.data.album; + } + + protected async handleArt(play: PlayObject, parts: ExternalMetadataTerm, transformData: ArtUriData): Promise { + if (parts === false) { + return play.meta.art; + } + if (typeof parts === 'object') { + if (parts.when !== undefined) { + if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { + this.logger.debug('When condition for duration not met, returning original duration'); + return play.meta.art; + } + } + } + + const { + type = 'album', + uri + } = transformData; + + const existing = play.meta?.art ?? {}; + + if(type === 'album') { + return { + ...existing, + album: uri + } + } + return { + ...existing, + artist: uri + } + } + + public notify(payload: WebhookPayload): Promise { + return; + } + +} + +export class SearchPrerequisiteError extends SimpleError { + name = 'Search Prerequistie Failure'; +} \ No newline at end of file diff --git a/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformerUtil.ts b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformerUtil.ts new file mode 100644 index 00000000..d041893c --- /dev/null +++ b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformerUtil.ts @@ -0,0 +1,85 @@ +import * as z from "zod"; +import { MaybeLogger } from "../../MaybeLogger.ts"; +import type { PlayObject, TransformerCommon, TransformOptions } from "../../../../core/Atomic.ts"; +import { caaArtType, thumbSizes, type CovertArtApiClientConfig } from "../../vendor/musicbrainz/CoverArtApiTypes.ts"; + +export const caaMissingTypes = z.enum(['album','track','artist']); +export type CAAMissingType = z.infer; +export const caaSizesConfig = z.enum([...thumbSizes.options, 'any']); +export type CAASizesConfig = z.infer; +export const caaAllowedTypesConfig = z.enum([...caaArtType.options, 'any']); +export type CAAAllowedTypesConfig = z.infer; + +export const coverArtArchiveTransformDataSchema = z.object({ + searchWhenMissing: z.union([z.literal(true),caaMissingTypes.array()]).optional(), + allowedTypes: caaAllowedTypesConfig.array().optional(), + allowedSizes: caaSizesConfig.array().optional(), + preferredSizes: thumbSizes.array().optional(), + forceSearch: z.boolean().optional(), +}); +export type CoverArtArchiveTransformData = z.infer; + +export type CovertArtArchiveTransformerConfig = TransformerCommon & { options?: TransformOptions }; + +export const configFromEnv = (logger: MaybeLogger = new MaybeLogger()) => { + const transformEnv = process.env.CAA_PRESETS; + let tConfig: CovertArtArchiveTransformerConfig; + if (transformEnv !== undefined && transformEnv.trim() !== '') { + tConfig = { + type: 'coverartarchive', + name: 'MSCAADefault', + data: { + apis: [ + { + enable: true + } + ] + }, + defaults: {} + }; + const presets = transformEnv.split(',').map(x => x.trim().toLocaleLowerCase()); + for (const pName of presets) { + const p = PRESETS[pName]; + if (p === undefined) { + logger.warn(`No preset with name '${p}'`); + continue; + } + tConfig.defaults = { + ...tConfig.defaults, + ...p, + }; + } + logger.debug(`Using presets: ${presets.join(',')}`); + } + + return tConfig; +} + +export const DEFAULTS_PRESET: CoverArtArchiveTransformData = { + searchWhenMissing: true, + allowedTypes: ['front'] +}; + +export const DEFAULTS_ANY: CoverArtArchiveTransformData = { + searchWhenMissing: true, + allowedTypes: ['any'] +}; + +export const PRESETS: Record = { + default: DEFAULTS_PRESET, + any: DEFAULTS_ANY, +}; + +export const hasArtFields = (play: PlayObject): CAAMissingType[] => { + const t: CAAMissingType[] = []; + if(play.meta.art?.album !== undefined) { + t.push('album'); + } + if(play.meta.art?.artist !== undefined) { + t.push('artist'); + } + if(play.meta.art?.track !== undefined) { + t.push('track'); + } + return t; +} \ No newline at end of file diff --git a/src/backend/common/vendor/musicbrainz/CoverArtApiClient.ts b/src/backend/common/vendor/musicbrainz/CoverArtApiClient.ts index ba6f8301..289962c7 100644 --- a/src/backend/common/vendor/musicbrainz/CoverArtApiClient.ts +++ b/src/backend/common/vendor/musicbrainz/CoverArtApiClient.ts @@ -5,59 +5,34 @@ import request from 'superagent'; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.ts"; import { UpstreamError } from "../../errors/UpstreamError.ts"; import { initMemoryCache } from "../../Cache.ts"; -import { joinedUrl } from "../../../utils/NetworkUtils.ts"; -import type {RequestRetryOptions} from "../../infrastructure/config/common.ts"; +import { joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.ts"; import type {RetryContext} from "p-retry"; import { NO_RETRY_HTTP_STATUS, tryApiCall } from "../../../utils/RequestUtils.ts"; - -export type ThumbSize = 250 | 500 | 1200; -const THUMB_SIZES = [250, 500, 1200]; - -export interface ThumbOptions { - type?: 'front' | 'back' - size?: ThumbSize - retries?: number -} - -export interface CoverArtReleaseImage { - types: ('Front' | 'Booklet' | 'Back')[] - front: boolean - back: boolean - image: string - comment: string - approve: boolean - id: string - thumbnails: { - 250: string - 500: string - 1200: string - small: string - large: string - } -} - -export interface CoverArtReleaseResponse { - /** URL to musicbrainz release */ - release: string - images: CoverArtReleaseImage[] -} - -export interface CoverArtApiConfig extends RequestRetryOptions { - url?: URL; -} +import { RateLimiterMemory, RateLimiterQueue } from "rate-limiter-flexible"; +import { type CoverArtReleaseResponse, DEFAULT_CAA_URL, THUMB_SIZES, type ThumbOptions, type CoverArtApiConfig } from "./CoverArtApiTypes.ts"; export class CoverArtApiClient extends AbstractApiClient { declare config: CoverArtApiConfig; cache: Cacheable; baseUrl: URL; + public rateLimiterQueue: RateLimiterQueue; constructor(name: any, config: CoverArtApiConfig, options: AbstractApiOptions & { cache?: Cacheable }) { super('CoverArtArchive', '', config, options); const { - url = new URL('https://coverartarchive.org'), + url = DEFAULT_CAA_URL,// new URL('https://coverartarchive.org'), + rate: { + requests = 100, + perTime = 1 + } = {} } = config; - this.baseUrl = url; + const u = normalizeWebAddress(url); + this.rateLimiterQueue = this.rateLimiterQueue = new RateLimiterQueue(new RateLimiterMemory({ + points: requests, + duration: perTime + }), { maxQueueSize: 20 }); + this.baseUrl = u.url; this.cache = options.cache ?? new Cacheable({ primary: initMemoryCache({ lruSize: 50 }) }); } @@ -116,6 +91,8 @@ export class CoverArtApiClient extends AbstractApiClient { protected coverThumbRequest = async (url: string): Promise => { try { + await this.rateLimiterQueue.removeTokens(1); + // TODO make tryApiCall accept async shouldRetry // https://musicbrainz.org/doc/Cover_Art_Archive/API#/release/{mbid}/({id}|front|back)-(250|500|1200) const resp = await tryApiCall(() => request .get(url) @@ -141,16 +118,27 @@ export class CoverArtApiClient extends AbstractApiClient { } } - getCovers = async (mbid: string): Promise => { - const cacheKey = `albumart-${mbid}`; + public getCoverThumbFromUrl = async (url: string): Promise => { + const cachedLocation = await this.cache.get(url); + if(cachedLocation !== undefined) { + return cachedLocation; + } + const location = await this.coverThumbRequest(url); + await this.cache.set(url, false, '1hr'); + return location; + } + + getCovers = async (mbid: string, mbidType: 'release' | 'release-group'): Promise => { + const cacheKey = `caa-covers-${mbidType}-${mbid}`; const cachedArt = await this.cache.get(cacheKey); if (cachedArt !== undefined) { return cachedArt; } else { try { + await this.rateLimiterQueue.removeTokens(1); // https://musicbrainz.org/doc/Cover_Art_Archive/API#/release/{mbid}/ const resp = await request - .get(joinedUrl(this.baseUrl, `/release/${mbid}`)) + .get(joinedUrl(this.baseUrl, `/${mbidType}/${mbid}`)) .redirects(3); await this.cache.set(cacheKey, resp.body, '1hr'); return resp.body as CoverArtReleaseResponse; diff --git a/src/backend/common/vendor/musicbrainz/CoverArtApiTypes.ts b/src/backend/common/vendor/musicbrainz/CoverArtApiTypes.ts new file mode 100644 index 00000000..87986527 --- /dev/null +++ b/src/backend/common/vendor/musicbrainz/CoverArtApiTypes.ts @@ -0,0 +1,110 @@ +import * as z from "zod"; +import { requestRetryOptionsSchema } from "../../infrastructure/config/common.ts"; + +const coverArtApiConfigSchema = z.object({ + /** + * Use this api configuration? + * + * @default true + */ + enable: z.boolean().optional().meta({description: 'Use this api configuration? Default is `true`.'}), + /** + * Base URL of a Rocksky server. Leave unset to use the official Musicbrainz instance. + * + */ + url: z.string().optional().meta({ + description: 'Base URL of a CoverArtArchive server. Leave unset to use the official CoverArtArchive instance.' + }), + /** + * milliseconds to wait until throwing a timeout error when waiting for a response. + * + * @default 6000 + */ + requestTimeout: z.int().optional().meta({ + description: 'milliseconds to wait until throwing a timeout error when waiting for a response. Default is `6000`ms.' + }), + rate: z.object({ + requests: z.number().positive().optional().meta({ + description: 'max number of requests allowed during perTime unit of time', + default: 1 + }), + perTime: z.number().positive().optional().meta({ + description: 'A span of time (in seconds) during which requests made me made, resets after perTime', + default: 1 + }) + }).optional(), + ...requestRetryOptionsSchema.shape +}); +export type CoverArtApiConfig = z.infer; + +const covertArtApiClientConfig = z.object({ + apis: coverArtApiConfigSchema.array().optional() +}); +export type CovertArtApiClientConfig = z.infer; + +export const DEFAULT_CAA_URL = 'https://coverartarchive.org'; + +export const thumbSizes = z.enum(['250','500','1200','small','large']); +export type ThumbSizeEnum = z.infer; +export type ThumbSize = 250 | 500 | 1200; +export const THUMB_SIZES = [250, 500, 1200]; +export interface ThumbOptions { + type?: 'front' | 'back'; + size?: ThumbSize; + retries?: number; +} +export const thumbnailsSchema = z.partialRecord(thumbSizes, z.string()); +export const coverReleaseImageTypes = z.enum(['Front','Back','Booklet']); +export type CoverReleaseImageTypes = z.infer; +const coverArtReleaseImageSchema = z.object({ + types: coverReleaseImageTypes.array(), + front: z.boolean(), + back: z.boolean(), + image: z.string(), + comment: z.string(), + approved: z.boolean(), + id: z.string(), + edit: z.number(), + thumbnails: thumbnailsSchema, +}) +export type CoverArtReleaseImage = z.infer; +// export interface CoverArtReleaseImage { +// types: ('Front' | 'Booklet' | 'Back')[]; +// front: boolean; +// back: boolean; +// image: string; +// comment: string; +// approve: boolean; +// id: string; +// thumbnails: { +// 250: string; +// 500: string; +// 1200: string; +// small: string; +// large: string; +// }; +// } +export interface CoverArtReleaseResponse { + /** URL to musicbrainz release */ + release: string; + images: CoverArtReleaseImage[]; +} + +export const caaArtType = z.enum(['front', 'back', 'booklet']); +export type CAAArtType = z.infer; + +export const coverImageHas = (cover: CoverArtReleaseImage): {sizes: ThumbSizeEnum[], types: CAAArtType[]} => { + const sizes: ThumbSizeEnum[] = Object.keys(cover.thumbnails).map(x => x.toString() as ThumbSizeEnum); + const types: CAAArtType[] = []; + if(cover.front === true || cover.types.includes(coverReleaseImageTypes.enum.Front)) { + types.push('front'); + } + if(cover.back === true || cover.types.includes(coverReleaseImageTypes.enum.Back)) { + types.push('back'); + } + if(cover.types.includes(coverReleaseImageTypes.enum.Booklet)) { + types.push('booklet'); + } + return {sizes, types}; +} + diff --git a/src/backend/common/vendor/musicbrainz/CovertArtApiPool.ts b/src/backend/common/vendor/musicbrainz/CovertArtApiPool.ts new file mode 100644 index 00000000..3b48f7cb --- /dev/null +++ b/src/backend/common/vendor/musicbrainz/CovertArtApiPool.ts @@ -0,0 +1,74 @@ +import AbstractApiClient from "../AbstractApiClient.ts"; +import type { Cacheable } from "cacheable"; +import type { AbstractApiOptions } from "../../infrastructure/Atomic.ts"; +import { getRoot } from "../../../ioc.ts"; +import { type CircuitBreakerProxy, ProxyWithCircuitBreaker } from "@foxxmd/load-balancer-proxy"; +import { normalizeWebAddress } from "../../../utils/NetworkUtils.ts"; +import { ConsecutiveBreaker } from "cockatiel"; +import { SimpleError } from "../../errors/MSErrors.ts"; +import { CoverArtApiClient } from "./CoverArtApiClient.ts"; +import { DEFAULT_CAA_URL, type CovertArtApiClientConfig } from "./CoverArtApiTypes.ts"; + +export type CovertArtSingletonMap = Map; + +export class CoverArtClientPool extends AbstractApiClient { + declare config: CovertArtApiClientConfig; + public proxy: CircuitBreakerProxy + cache: Cacheable; + + constructor(name: any, config: CovertArtApiClientConfig, options: AbstractApiOptions & { cache?: Cacheable }) { + const {apis = [{enable: true}]} = config; + super('CAA Pool', name, {...config, apis}, options); + this.cache = options.cache ?? getRoot().items.cache().cacheApi; + + const caMap = getRoot().items.caMap(); + + const usedApis: CoverArtApiClient[] = []; + const hosts: string[] = []; + for (const apiConfig of this.config.apis) { + if ((apiConfig.enable ?? true) === false) { + this.logger.verbose(`Not using config for ${apiConfig.url ?? DEFAULT_CAA_URL} because it is disabled`); + continue; + } + const { + rate = {}, + url, + ...rest + } = apiConfig; + const u = normalizeWebAddress((url ?? DEFAULT_CAA_URL).toLocaleLowerCase()); + hosts.push(u.url.hostname); + const rs = caMap.get(u.url.hostname); + let points: number, + duration: number; + if (rs === undefined) { + switch (u.url.hostname) { + default: + points = rate.requests ?? 100; + duration = rate.perTime ?? 1; + break; + } + const api = new CoverArtApiClient(undefined, {rate: {requests: points, perTime: duration}, ...rest}, {logger: this.logger}); + caMap.set(u.url.hostname, api); + usedApis.push(api); + this.logger.verbose(`Created CoverArtArchive API for ${u.url.hostname} with Rate Limit ${points}req/${duration}s`); + } else { + usedApis.push(rs); + } + } + this.proxy = ProxyWithCircuitBreaker.create(usedApis,() => ({ + halfOpenAfter: 30000, + breaker: new ConsecutiveBreaker(3), + onFailure: ({reason, duration}) => { + this.logger.warn(new SimpleError(`Error occurred after ${duration}ms, will try next host`, {cause: reason, shortStack: true})); + }, + }), { + comparer: async (a, b) => await b.rateLimiterQueue.getTokensRemaining() - await a.rateLimiterQueue.getTokensRemaining() + }) + this.logger.debug(`Rate limit prioritized API calls using hosts: ${hosts.join(' | ')}`); + } + + protected getIdentifier(): string { + return 'CAAAPI'; + } + +} \ No newline at end of file diff --git a/src/backend/ioc.ts b/src/backend/ioc.ts index f62af138..8b374442 100644 --- a/src/backend/ioc.ts +++ b/src/backend/ioc.ts @@ -17,6 +17,7 @@ import { version, stable } from "./version.ts"; import type {DbConcrete} from "./common/database/drizzle/drizzleUtils.ts"; import type { MSBackendEventMap } from "./common/infrastructure/MSBackendEventMap.ts"; import type { RockskySingletonMap } from "./common/vendor/rocksky/RockskyClientWrapped.ts"; +import type { CovertArtSingletonMap } from "./common/vendor/musicbrainz/CovertArtApiPool.ts"; let root: ReturnType; export interface RootOptions { @@ -29,6 +30,7 @@ export interface RootOptions { cache?: CacheConfigOptions | MSCache | (() => MSCache) mbMap?: MusicBrainzSingletonMap | (() => MusicBrainzSingletonMap) rsMap?: RockskySingletonMap | (() => RockskySingletonMap) + caMap?: CovertArtSingletonMap | (() => CovertArtSingletonMap) transformers?: TransformerCommonConfig[] db?: DbConcrete | (() => Promise) } @@ -75,6 +77,7 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => { cache, mbMap, rsMap, + caMap, db, transformers = [] } = options || {}; @@ -114,6 +117,16 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => { maybeSingletonRs = new Map(); } + let caFunc: () => CovertArtSingletonMap; + let maybeSingletonCa: CovertArtSingletonMap; + if(typeof caMap === 'function') { + caFunc = caMap; + } else if(maybeSingletonCa !== undefined) { + maybeSingletonCa = caMap; + } else { + maybeSingletonCa = new Map(); + } + let dbFunc: () => Promise; if(typeof db === 'function') { dbFunc = db; @@ -183,6 +196,7 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => { cache: () => maybeSingletonCache !== undefined ? () => maybeSingletonCache : cacheFunc, mbMap: () => maybeSingletonMb !== undefined ? () => maybeSingletonMb : mbFunc, rsMap: () => maybeSingletonRs !== undefined ? () => maybeSingletonRs : rsFunc, + caMap: () => maybeSingletonCa !== undefined ? () => maybeSingletonCa : caFunc, coverArtApi, db: () => dbFunc }).add((items) => { diff --git a/src/backend/tests/coverartarchive/caaFixtures.ts b/src/backend/tests/coverartarchive/caaFixtures.ts new file mode 100644 index 00000000..1ad93d0c --- /dev/null +++ b/src/backend/tests/coverartarchive/caaFixtures.ts @@ -0,0 +1,28 @@ +import { faker } from "@faker-js/faker"; +import { coverReleaseImageTypes, type CoverArtReleaseImage, type CoverArtReleaseResponse } from "../../common/vendor/musicbrainz/CoverArtApiTypes.ts"; +import { generateArray } from "../../../core/DataUtils.ts"; + +export const generateCoverReleaseImageResponse = (partial: Partial = {}): CoverArtReleaseImage => ({ + types: faker.helpers.arrayElements(coverReleaseImageTypes.options), + front: faker.datatype.boolean(), + back: faker.datatype.boolean(), + approved: faker.datatype.boolean(), + id: faker.number.int().toString(), + edit: faker.number.int(), + image: faker.internet.url(), + comment: '', + thumbnails: { + "250": faker.internet.url(), + "500": faker.internet.url(), + "1200": faker.internet.url(), + "small": faker.internet.url(), + "large": faker.internet.url() + }, + ...partial +}) + +export const generateCoverResponse = (partial: Partial = {}): CoverArtReleaseResponse => ({ + release: faker.internet.url(), + images: generateArray(faker.number.int({ min: 1, max: 3 }), () => generateCoverReleaseImageResponse()), + ...partial +}) \ No newline at end of file diff --git a/src/backend/tests/coverartarchive/coverartarchive.test.ts b/src/backend/tests/coverartarchive/coverartarchive.test.ts new file mode 100644 index 00000000..c7d71305 --- /dev/null +++ b/src/backend/tests/coverartarchive/coverartarchive.test.ts @@ -0,0 +1,227 @@ +import * as dotenv from 'dotenv'; +import { loggerTest } from "@foxxmd/logging"; +import chai, { expect } from 'chai'; +import asPromised from 'chai-as-promised'; +import { before, describe, it } from 'mocha'; +import { initMemoryCache } from "../../common/Cache.ts"; +import { Cacheable } from "cacheable"; +import { getPathFromCWD } from '../../common/index.ts'; +import path from 'path'; +import { withRequestInterception } from '../utils/networking.ts'; +import { http, HttpResponse } from "msw"; +import { generatePlay, withBrainz } from '../../../core/tests/utils/PlayTestUtils.ts'; +import { CoverArtApiClient } from '../../common/vendor/musicbrainz/CoverArtApiClient.ts'; +import { type CoverArtApiConfig } from '../../common/vendor/musicbrainz/CoverArtApiTypes.ts'; +import CoverArtArchiveTransformer from '../../common/transforms/coverartarchive/CoverArtArchiveTransformer.ts'; +import { generateCoverReleaseImageResponse, generateCoverResponse } from './caaFixtures.ts'; +import { faker } from '@faker-js/faker'; + +chai.use(asPromised); + +const envPath = path.join(getPathFromCWD(), '.env'); +dotenv.config({ path: envPath }); + +const memorycache = () => new Cacheable({ primary: initMemoryCache({ ttl: '1ms' }) }); + +const createTransformer = () => new CoverArtArchiveTransformer({ + name: 'test', + type: 'coverartarchive', + }, { + logger: loggerTest, + clientCache: memorycache(), + cache: memorycache() + }) +const transformer = createTransformer(); + +describe('CoverArtArchive Transformer', function () { + + describe('Basic Operations', function () { + + it('responds', async function () { + + this.timeout(350000); + + const coverResponse = generateCoverResponse({images: [generateCoverReleaseImageResponse()]}); + const imageResponse = faker.internet.url(); + + await withRequestInterception( + [ + http.get(/coverartarchive\.org\/(release|release-group)\/.+/, () => HttpResponse.json(coverResponse, { status: 200 })), + http.get(/.+/, () => new HttpResponse(null, { status: 302, headers: {location: imageResponse} })) + ], async function() { + await transformer.initialize(); + const play = withBrainz(generatePlay(), {include: ['album']}); + const res = await transformer.getTransformerData(play, { + type: 'coverartarchive' + }); + expect(res.release).eq(coverResponse.release); + } + )(); + }); + + it('tries both mbid types', async function () { + + this.timeout(350000); + + const coverResponse = generateCoverResponse({images: [generateCoverReleaseImageResponse()]}); + const imageResponse = faker.internet.url(); + + await withRequestInterception( + [ + http.get('https://coverartarchive.org/release-group/:mbid', () => HttpResponse.json(coverResponse, { status: 200 })), + http.get('https://coverartarchive.org/release/:mbid', () => new HttpResponse(null, {status: 404})), + http.get(/.+/, () => new HttpResponse(null, { status: 302, headers: {location: imageResponse} })) + ], async function() { + await transformer.initialize(); + const play = withBrainz(generatePlay(), {include: ['album','releaseGroup']}); + const res = await transformer.getTransformerData(play, { + type: 'coverartarchive' + }); + expect(res.type).eq('releaseGroup'); + expect(res.lifecycleInputs.find(x => x.type.includes('album-prereqFailure'))); + } + )(); + }); + + it('should not return result if filtered out by requirement for type', async function () { + + this.timeout(350000); + + const coverResponse = generateCoverResponse({images: [generateCoverReleaseImageResponse({types: ['Back'], back: true, front: false})]}); + const imageResponse = faker.internet.url(); + + await withRequestInterception( + [ + http.get('https://coverartarchive.org/release/:mbid', () => HttpResponse.json(coverResponse, { status: 200 })), + http.get(/.+/, () => new HttpResponse(null, { status: 302, headers: {location: imageResponse} })) + ], async function() { + await transformer.initialize(); + const play = withBrainz(generatePlay(), {include: ['album']}); + const res = await transformer.getTransformerData(play, { + type: 'coverartarchive', + allowedTypes: ['front'] + }); + expect(res.images).length(0) + } + )(); + }); + + it('should not return result if filtered out by requirement for size', async function () { + + this.timeout(350000); + + const coverResponse = generateCoverResponse({images: [generateCoverReleaseImageResponse({thumbnails: {"250": faker.internet.url()}})]}); + const imageResponse = faker.internet.url(); + + await withRequestInterception( + [ + http.get('https://coverartarchive.org/release/:mbid', () => HttpResponse.json(coverResponse, { status: 200 })), + http.get(/.+/, () => new HttpResponse(null, { status: 302, headers: {location: imageResponse} })) + ], async function() { + await transformer.initialize(); + const play = withBrainz(generatePlay(), {include: ['album']}); + const res = await transformer.getTransformerData(play, { + type: 'coverartarchive', + allowedSizes: ['500'] + }); + expect(res.images).length(0) + } + )(); + }); + + it('should return preferred size for transform', async function () { + + const coverResponse = generateCoverResponse({images: [generateCoverReleaseImageResponse({ + thumbnails: { + "250": faker.internet.url(), + "500": faker.internet.url() + }, + types: ['Front'], + front: true + })]}); + const imageResponse = coverResponse.images[0].thumbnails["500"]; + + await withRequestInterception( + [ + http.get('https://coverartarchive.org/release/:mbid', () => HttpResponse.json(coverResponse, { status: 200 })), + http.get(/.+/, () => new HttpResponse(null, { status: 302, headers: {location: imageResponse} })) + ], async function() { + await transformer.initialize(); + const play = withBrainz(generatePlay(), {include: ['album']}); + const res = await transformer.getTransformerData(play, { + type: 'coverartarchive', + preferredSizes: ['1200','500'] + }); + const data = await transformer.handlePostFetch(play, + res, + { + type: 'coverartarchive', + preferredSizes: ['1200','500'] + }) + expect(data).to.exist; + expect(data.uri).eq(coverResponse.images[0].thumbnails["500"]) + } + )(); + }); + + }); + +}); + +const createCoverArtApi = (config: CoverArtApiConfig = {}) => new CoverArtApiClient('test', config, {logger: loggerTest}) +const CA_MOCK_URL_STR = 'http://coverartarchive.org'; +//const CA_MOCK_URL_REG = /coverartarchive\.org/; +const CA_MOCK_URL = CA_MOCK_URL_STR; +const RELEASE = '76df3287-6cda-33eb-8e9a-044b5e15ffdd'; +const INVALID_RELEASE = '76df3287-6cda-33eb-8e9a-044b5e15ffde'; +const RELEASE_GROUP = 'dc7bec45-b321-4dfc-be66-96eab5acb36e'; + +describe('#CoverArt CoverArtArchive API', function () { + it('Should get url from response', + withRequestInterception( + [ + http.get(`${CA_MOCK_URL_STR}/release/${RELEASE}/front-250`, () => new HttpResponse(null, { status: 307, headers: {location: `${CA_MOCK_URL_STR}/download/1.jpg`} }) + ), + http.get(`${CA_MOCK_URL_STR}/download/1.jpg`, () => new HttpResponse(null, { status: 302, headers: {location: `${CA_MOCK_URL_STR}/cdn/1.jpg`} })) + ], + async function () { + const api = createCoverArtApi({url: CA_MOCK_URL}); + const resp = await api.getCoverThumb(RELEASE, 'release', {size: 250}); + expect(resp).is.not.undefined; + expect(resp).eq(`${CA_MOCK_URL_STR}/cdn/1.jpg`) + } + )); + + it('Should return undefined if release not found', + withRequestInterception( + [ + http.get(`${CA_MOCK_URL_STR}/release/${INVALID_RELEASE}/front-250`, () => new HttpResponse(null, { status: 404}) + ), + ], + async function () { + const api = createCoverArtApi({url: CA_MOCK_URL}); + const resp = await api.getCoverThumb(INVALID_RELEASE, 'release', {size: 250}); + expect(resp).is.undefined; + } + )); + + describe('#CoverArt Real API', function() { + + before(function () { + if (process.env.CAA_TEST !== 'true') { + this.skip(); + } + }); + + it('Returns a response for release', async function() { + const api = createCoverArtApi(); + const resp = await api.getCoverThumb(RELEASE, 'release', {size: 250}); + expect(resp).is.not.undefined; + }); + it('Returns a response for release-group', async function() { + const api = createCoverArtApi(); + const resp = await api.getCoverThumb(RELEASE_GROUP, 'release-group', {size: 250}); + expect(resp).is.not.undefined; + }); + }); +}); \ No newline at end of file diff --git a/src/backend/tests/musicbrainz/musicbrainz.test.ts b/src/backend/tests/musicbrainz/musicbrainz.test.ts index a5ababb2..92c8b8af 100644 --- a/src/backend/tests/musicbrainz/musicbrainz.test.ts +++ b/src/backend/tests/musicbrainz/musicbrainz.test.ts @@ -15,11 +15,12 @@ import { MockNetworkError, withRequestInterception } from '../utils/networking.t import { http, HttpResponse, delay } from "msw"; import { generatePlay, withBrainz } from '../../../core/tests/utils/PlayTestUtils.ts'; import { intersect, missingMbidTypes, sleep } from '../../utils.ts'; -import { CoverArtApiClient, type CoverArtApiConfig } from '../../common/vendor/musicbrainz/CoverArtApiClient.ts'; +import { CoverArtApiClient } from '../../common/vendor/musicbrainz/CoverArtApiClient.ts'; import { artistNamesToCredits, artistNameToCredit } from '../../../core/StringUtils.ts'; import dayjs from 'dayjs'; import { MusicbrainzApiWrapped } from '../../common/vendor/musicbrainz/MusicbrainzApi.ts'; import { AsyncLocalStorage } from 'async_hooks'; +import type { CoverArtApiConfig } from '../../common/vendor/musicbrainz/CoverArtApiTypes.ts'; chai.use(asPromised); @@ -639,67 +640,4 @@ describe('#MB Missing Types', function() { expect(intersect(DEFAULT_MISSING_TYPES, missing)).length.is.greaterThan(0); }); -}); - -const CA_MOCK_URL_STR = 'http://coverartarchive.org'; -//const CA_MOCK_URL_REG = /coverartarchive\.org/; -const CA_MOCK_URL = new URL(CA_MOCK_URL_STR); -const RELEASE = '76df3287-6cda-33eb-8e9a-044b5e15ffdd'; -const INVALID_RELEASE = '76df3287-6cda-33eb-8e9a-044b5e15ffde'; -const RELEASE_GROUP = 'dc7bec45-b321-4dfc-be66-96eab5acb36e'; - -describe('#CoverArt CoverArtArchive API', function () { - it('Should get url from response', - withRequestInterception( - [ - http.get(`${CA_MOCK_URL_STR}/release/${RELEASE}/front-250`, () => { - return new HttpResponse(null, { status: 307, headers: {location: `${CA_MOCK_URL_STR}/download/1.jpg`} }); - } - ), - http.get(`${CA_MOCK_URL_STR}/download/1.jpg`, () => { - return new HttpResponse(null, { status: 302, headers: {location: `${CA_MOCK_URL_STR}/cdn/1.jpg`} }); - }) - ], - async function () { - const api = createCoverArtApi({url: CA_MOCK_URL}); - const resp = await api.getCoverThumb(RELEASE, 'release', {size: 250}); - expect(resp).is.not.undefined; - expect(resp).eq(`${CA_MOCK_URL_STR}/cdn/1.jpg`) - } - )); - - it('Should return undefined if release not found', - withRequestInterception( - [ - http.get(`${CA_MOCK_URL_STR}/release/${INVALID_RELEASE}/front-250`, () => { - return new HttpResponse(null, { status: 404}); - } - ), - ], - async function () { - const api = createCoverArtApi({url: CA_MOCK_URL}); - const resp = await api.getCoverThumb(INVALID_RELEASE, 'release', {size: 250}); - expect(resp).is.undefined; - } - )); - - describe('#CoverArt Real API', function() { - - before(function () { - if (process.env.MB_TEST !== 'true') { - this.skip(); - } - }); - - it('Returns a response for release', async function() { - const api = createCoverArtApi(); - const resp = await api.getCoverThumb(RELEASE, 'release', {size: 250}); - expect(resp).is.not.undefined; - }); - it('Returns a response for release-group', async function() { - const api = createCoverArtApi(); - const resp = await api.getCoverThumb(RELEASE_GROUP, 'release-group', {size: 250}); - expect(resp).is.not.undefined; - }); - }); }); \ No newline at end of file diff --git a/src/core/Transform.ts b/src/core/Transform.ts index 12a1978f..d62e6072 100644 --- a/src/core/Transform.ts +++ b/src/core/Transform.ts @@ -21,7 +21,7 @@ export interface PlayTransformPartsAtomic { } export const STAGE_TYPES_USER: StageTypeUser[] = ['user']; -export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['musicbrainz','native','rocksky']; +export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['musicbrainz','native','rocksky','coverartarchive']; export const STAGE_TYPES: StageType[] = [...STAGE_TYPES_METADATA, ...STAGE_TYPES_USER]; export interface StageTyped { @@ -173,9 +173,9 @@ export const flowControlSchema = z.object({ export type FlowControl = z.infer; -export const stageTypeMetadataSchema = z.enum(['musicbrainz', 'native', 'rocksky']).meta({title: 'Stage Type Metadata'}); +export const stageTypeMetadataSchema = z.enum(['musicbrainz', 'native', 'rocksky', 'coverartarchive']).meta({title: 'Stage Type Metadata'}); -export const typedStageSchema = z.enum(['musicbrainz', 'native','user', 'rocksky']).meta({title: 'Stage Type'}); +export const typedStageSchema = z.enum(['musicbrainz', 'native','user', 'rocksky', 'coverartarchive']).meta({title: 'Stage Type'}); export type StageTypeMetadata = z.infer; @@ -259,7 +259,7 @@ const playTransformUserStageRulesSchema = z.object({ }).meta({title: 'User Stage Rules'}); const metadataStageForUnionSchema = playTransformMetadataStageSchema.extend({ - type: z.enum(['musicbrainz','rocksky']), + type: z.enum(['musicbrainz','rocksky','coverartarchive']), }).meta({title: 'Transform External Stage'}); const playTransformTypedStageOptionsSchema = z.discriminatedUnion('type', [ diff --git a/src/core/tests/utils/PlayTestUtils.ts b/src/core/tests/utils/PlayTestUtils.ts index ca18625f..26a58d02 100644 --- a/src/core/tests/utils/PlayTestUtils.ts +++ b/src/core/tests/utils/PlayTestUtils.ts @@ -247,7 +247,7 @@ export const generateJsonPlays = (...args: Parameters): Js } export interface WithBrainzOptions { - include: ('track' | 'artist' | 'album' | 'recording')[] + include: ('track' | 'artist' | 'album' | 'recording' | 'releaseGroup')[] } export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): BrainzMeta => { const {include} = opts; @@ -269,6 +269,11 @@ export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): Brain brainz.album = generateMbid(); } break; + case 'releaseGroup': + if(play.data.meta?.brainz?.releaseGroup === undefined) { + brainz.releaseGroup = generateMbid(); + } + break; case 'artist': if(play.data.meta?.brainz?.artist === undefined && (play.data.artists ?? []).length > 0) { const artistMbids = play.data.artists.map(x => generateMbid()); -- 2.51.2