From a2580583dac70859df7d617a2b2d151f5aa0f418 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Sat, 26 Sep 2026 15:19:27 -0400 Subject: [PATCH] docs: Add Cover Art Archive guidance and fix some bugs/usage for it --- .../transforms/coverartarchive.mdx | 549 ++++++++++++++++++ .../configuration/transforms/transforms.mdx | 1 + .../common/transforms/TransformerManager.ts | 14 + .../CoverArtArchiveTransformer.ts | 41 +- .../CoverArtArchiveTransformerUtil.ts | 17 +- 5 files changed, 592 insertions(+), 30 deletions(-) create mode 100644 docsite/docs/configuration/transforms/coverartarchive.mdx diff --git a/docsite/docs/configuration/transforms/coverartarchive.mdx b/docsite/docs/configuration/transforms/coverartarchive.mdx new file mode 100644 index 00000000..49dfad4a --- /dev/null +++ b/docsite/docs/configuration/transforms/coverartarchive.mdx @@ -0,0 +1,549 @@ +--- +title: Cover Art Archive Stage +description: Enhancing Scrobbles Art with Cover Art Archive +toc_min_heading_level: 2 +toc_max_heading_level: 5 +--- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +The **Cover Art Archive** [Stage](/configuration/transforms#stage) finds album art for your Play using [Cover Art Archive](https://coverartarchive.org/), a free and open collection of album art that is part of [MusicBrainz](https://musicbrainz.org/). + +**This Stage is useful for adding artwork to scrobbles from Sources that do not provide any art.** + +This Stage is simpler than other Stages: it **only** changes the **art** of your Play and **only** runs when your Play does not already have art (by default). + +:::important + +This Stage finds art by using the MusicBrainz ID ([MBID](https://musicbrainz.org/doc/MusicBrainz_Identifier)) of the album that is already in your Play data. It does **not** search using the album name or artist name. + +If your Play does not have an album MBID then this Stage cannot find art. Use a Stage that adds MBIDs, like the [Musicbrainz Stage](/configuration/transforms/musicbrainz) or the [Rocksky Stage](/configuration/transforms/rocksky), **before** this Stage. See [**Get MBIDs First**](#get-mbids-first). + +::: + +:::tip + +If you are using [ENV Config](/configuration?config-type=env#configuration-types) for multi-scrobbler and just want a quick and easy setup, skip to [**ENV Configuration**](#env-configuration). + +::: + +:::tip + +Set up [Valkey Caching](/configuration?cachedThings=metadata#caching) to cache Cover Art Archive API calls for faster processing. + +::: + +## Configuration + +### API Setup + +No API setup is required. APIs can still be configured in the event you are using mirrors, a proxy, or want to configure different rate limiting. + +To use the default Stage in [Rules and Hooks](#rules-and-hooks) either omit `name` or specify it as `"name": "MSDefault"`. + +
+ +Non-Default Servers and Rate Limiting + +Other, or _additional_, Cover Art Archive Servers/Mirrors can be added to the API configuration. **If more than one server is defined then multi-scrobbler will load balance requests based on available rate capacity.** + +Use `url` to define the base URL of the Cover Art Archive server to use. If `url` is not defined multi-scrobbler assumes it is the primary Cover Art Archive server, `https://coverartarchive.org`. + +Example of multiple servers: + +```json5 title="config.json" +{ + // ... + "transformers": [ + { + "type": "coverartarchive", + "name": "MyCAA", + "data": { + "apis": [ + { + "enable": true + // uses default Cover Art Archive server https://coverartarchive.org + }, + // additional server + { + "enable": true, + "url": "https://my.caa.mirror.domain.com" + } + ] + }, + } + ] +} +``` + +

Rate Limiting

+ +Cover Art Archive servers can optionally be configured with rate limiting. + +Rate limiting is defined by **max number of requests** within **timespan of N seconds.** If no rate limit is configured then a default of `100 req/s` is used. + +Example of configuring rate limit: + +```json5 title="config.json" +{ + // ... + "transformers": [ + { + "type": "coverartarchive", + "name": "MyCAA", + "data": { + "apis": [ + { + "enable": true, + "rate": { + // IE 10 req/s + "requests": 10, // maximum of 10 requests + "perTime": 1 // can be made within 1 second + } + } + ] + }, + } + ] +} +``` + +
+ +### Stage Configuration + +All of the properties found in the [**Finding Art**](#finding-art) section are configured in [Stage Configuration](/configuration/transforms#configuring-stages) as `defaults`. + +Example: + +```json5 title="config.json" +{ + // ... + "transformers": [ + { + "type": "coverartarchive", + "name": "MyCAA", + "defaults": { + "allowedTypes": ["any"], + "preferredSizes": ["500"] + } + } + ] +} +``` + +### Rules and Hooks + +[Add your Stage](/configuration/transforms/#stage) to a Source or Client by specifying it in a [Hook](/configuration/transforms/#hook): + +```json5 title="discord.json" +[ + { + "name": "MyDiscord", + "data": { /* ... */}, + "options": { + "playTransform": { + "preCompare": [ + { + "type": "coverartarchive", + "name": "MSDefault" + } + ] + } + } + } +] +``` + +This Stage only has one [**Stage Rule**](/configuration/transforms#stage-rules): `art`. It should be either a boolean, specifying if the found art should be used, or a [`when` condition](/configuration/transforms#conditional-modification). If `art` is not set it defaults to `true`. + +
+ +Example + +```json5 title="discord.json" +[ + { + "name": "MyDiscord", + "data": { /* ... */}, + "options": { + "playTransform": { + "preCompare": [ + { + "type": "coverartarchive", + "name": "MSDefault", + "art": { + "when": {/* ... */}, // will only apply art to Play if "when" is satisfied + } + } + ] + } + } + } +] +``` + +
+ +:::tip[Per Component Override] + +The `defaults` you set in [Stage Configuration](#stage-configuration) [can be overriden/added to](/configuration/transforms/#overriding-configuration) (per property) in each Hook. + +
+ +Example + +```json5 title="discord.json" +[ + { + "name": "MyDiscord", + "data": { /* ... */}, + "options": { + "playTransform": { + "preCompare": [ + { + "type": "coverartarchive", + "name": "MSDefault", + "allowedTypes": ["any"], // override from defaults + } + ] + } + } + } +] +``` + +
+ +::: + +## ENV Configuration + +The general configuration shown above can also be configured from a selection of *presets* using [ENV Config](/configuration?configType=env#configuration-types) for individual Sources/Clients. + +To configure [stage defaults](#stage-configuration) use `CAA_PRESETS` with a comma-delimited list of presets you wish to apply. More than one preset can be applied, in which case they combine. If `CAA_PRESETS` is not set the stage will use defaults. + +* `default` - Only uses art that is marked as the **front cover** of the album. This is the same as using no presets. +* `any` - Uses art of [**any type**](#art-type): front cover, back cover, or booklet. + +Finally, use ENV `*_TRANSFORMS=coverartarchive` on each Source/Client you wish to apply this stage to. This applies the stage in the [`preCompare` Hook](/configuration/transforms#lifecycle-hooks) with all [Rules](#rules-and-hooks) enabled. + +The `*` stands for the prefix used for each Source/Client's ENV keys. Refer to the individual Source/Client Configuration sections to find this. Example: + +* All [Jellyfin Sources](/configuration/sources/jellyfin) ENVs look like `JELLYFIN_URL=192.168.0.110:8096` etc... +* Use `JELLYFIN_TRANSFORMS=coverartarchive` + +:::tip + +Stages in `*_TRANSFORMS` run in the order they are written. To [get MBIDs first](#get-mbids-first), list the stage that adds MBIDs **before** `coverartarchive`, like `JELLYFIN_TRANSFORMS=musicbrainz,coverartarchive` + +::: + +
+ +Example Full Docker Deploy with ENV Configuration + +Using [Jellyfin](/quickstart#create-docker-compose-file) example from Quickstart with the Musicbrainz Stage and the Cover Art Archive Stage: + +```yaml +services: + multi-scrobbler: + image: foxxmd/multi-scrobbler + container_name: multi-scrobbler + environment: + - MB_PRESETS=default + // highlight-start + # allow any type of art, not only front covers + - CAA_PRESETS=any + // highlight-end + - JELLYFIN_URL=192.168.0.110:8096 + - JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b + - JELLYFIN_USER=MyUser + // highlight-start + # adds MBIDs with Musicbrainz Stage, then finds art with Cover Art Archive Stage + - JELLYFIN_TRANSFORMS=musicbrainz,coverartarchive + // highlight-end + - MALOJA_URL=http://domain.tld:42010 + - MALOJA_API_KEY=1234 + + volumes: + - "./config:/config" + ports: + - "9078:9078" + restart: unless-stopped +``` + +
+ +## Finding Art + +:::note + +**All properties found in this section are optional.** + +::: + +### Should MS Search? + +Before MS searches for art it checks if your Play already has **album** art. By default, **if your Play has album art then the Stage [is **skipped**.](/configuration/transforms/#flow-control)** + +Use these options to change when MS searches: + +* `forceSearch` (default `false`) - Always search, even when your Play already has album art + +
+ +Example + +[Stage Configuration](/configuration/transforms#configuring-stages) example: + +```json5 +// ... +"defaults": { + // uncomment to make the stage always search, even if album art is present + //"forceSearch": true + } +``` + +
+ +### How MS Searches + +MS looks up art using the MBIDs in your Play, in this order: + +1. The **album** MBID (MusicBrainz calls this a [Release](https://musicbrainz.org/doc/Release)). This is the exact version of the album you listened to, like a special edition or a specific country's release. +2. The **album group** MBID (MusicBrainz calls this a [Release Group](https://musicbrainz.org/doc/Release_Group)). This covers all versions of the same album. MS only uses it when step 1 does not find art that meets your requirements. + +When one step finds art that meets your requirements, MS stops and uses that art. + +:::note + +If your Play has neither MBID, or no art meets your requirements, then the stage is marked as [**failed** (`onFailure`) for **Flow Control**](/configuration/transforms/#flow-control). + +::: + +### Art Type + +Cover Art Archive labels each image with what it shows. Use `allowedTypes` to choose which images MS may use. Can contain any of: + +* `front` - The front cover of the album +* `back` - The back cover of the album +* `booklet` - A page from the booklet that comes with the album +* `any` - Any image, no matter what it shows + +The default is `["front"]`. + +:::tip + +If you list more than one type then an image must be labeled with **all** of them. For example, `["front", "back"]` only allows images labeled as both a front **and** a back cover. To allow any image use `["any"]`. + +**Note:** Not all art is labelled correctly, or at all, on CAA. If you just want *some* album art for your data then you should use `any`. + +::: + +```json5 +// ... +"defaults": { + // allow any image + "allowedTypes": ["any"] + } +``` + +### Art Size + +Cover Art Archive has each image in different sizes. Sizes are measured in pixels (the width of the image). Available sizes are: + +* `250` - small +* `500` - medium +* `1200` - large + +There are two options for sizes: + +* `allowedSizes` (default `["any"]`) - Only use images that are available in these sizes. An image must be available in **at least one** size you list, or use `any` to allow any image. +* `preferredSizes` (default `["250", "500", "1200"]`) - The sizes you want, in order. MS uses the first size in your list that is available. If none are available, MS uses any size the image has. + +```json5 +// ... +"defaults": { + // allow art with either 1200 or 500 sizes + "allowedSizes": ["1200","500"], + // prefer the largest size first + "preferredSizes": ["1200", "500"] + } +``` + +## Best Practices + +### Caching + +You **should** setup [metadata caching](/configuration/transforms#caching) to reduce API calls, improve transform performance, and reduce memory usage when using this stage. + +### Get MBIDs First + +This Stage needs an album MBID in your Play. Many Sources do not provide MBIDs. Put a Stage that adds MBIDs, like [Musicbrainz](/configuration/transforms/musicbrainz) or [Rocksky](/configuration/transforms/rocksky), **before** this Stage in the same Hook. + +Set [`failureReturnPartial: true`](/configuration/transforms/#flow-control) on this Stage. Then, if this Stage cannot find art, the changes from the earlier Stage are still kept. + +
+ +Example + +```json5 title="jellyfin.json" +[ + { + "name": "MyJellyfin", + "data": { /* ... */}, + "options": { + "playTransform": { + "preCompare": [ + { + // adds MBIDs to the Play + "type": "musicbrainz", + "name": "MSDefault" + }, + { + // uses the MBIDs to find art + "type": "coverartarchive", + "name": "MSDefault", + // keep Musicbrainz changes even if no art is found + "failureReturnPartial": true + } + ] + } + } + } +] +``` + +
+ +## Examples + +### Minimal + +
+ +Example + +In a [Jellyfin Source](/configuration/sources/jellyfin) [File Config](/configuration?configType=file#configuration-types): + +```json5 title="jellyfin.json" +[ + { + "name": "MyJellyfin", + "data": { /* ... */}, + "options": { + "playTransform": { + "preCompare": [ + { + "type": "coverartarchive", + "name": "MSDefault" + } + ] + } + } + } +] +``` + +Or using a [Jellyfin Source](/configuration/sources/jellyfin) with [ENV Config](/configuration?configType=env#configuration-types): + +```yaml +services: + multi-scrobbler: + image: foxxmd/multi-scrobbler + environment: + # ... your source ENVs go here + # + // highlight-start + # applies Cover Art Archive Stage to preCompare of Jellyfin Source + - JELLYFIN_TRANSFORMS=coverartarchive + // highlight-end + + volumes: + - "./config:/config" + ports: + - "9078:9078" + restart: unless-stopped +``` + +
+ +### Add Album Art for Discord + +The [Discord](/configuration/clients/discord) Client shows album art when your Play has art. Use the Musicbrainz Stage and the Cover Art Archive Stage on the `preCompare` hook for your Discord client so that missing art is added. + +
+ +Example + + + + Using the [ENV Configuration](#env-configuration) from above, add these fields to your docker compose `environment:` + + ```yaml + - CAA_PRESETS=any + - DISCORD_TRANSFORMS=musicbrainz,coverartarchive + ``` + + + In your [Discord](/configuration/clients/discord) [File Config](/configuration?configType=file#configuration-types): + + ```json5 title="discord.json" + [ + { + "name": "MyDiscord", + "data": { /* ... */}, + "options": { + "playTransform": { + "preCompare": [ + { + "type": "musicbrainz", + "name": "MSDefault" + }, + { + "type": "coverartarchive", + "name": "MSDefault", + "failureReturnPartial": true + } + ] + } + } + } + ] + ``` + + + Your [AIO Config](/configuration?configType=aio#configuration-types): + + ```json5 title="config.json" + { + // ... + "clients": [ + { + "name": "MyDiscord", + "type": "discord", + "data": { /* ... */}, + "options": { + "playTransform": { + "preCompare": [ + { + "type": "musicbrainz", + "name": "MSDefault" + }, + { + "type": "coverartarchive", + "name": "MSDefault", + "failureReturnPartial": true + } + ] + } + } + } + ] + } + ``` + + + +
diff --git a/docsite/docs/configuration/transforms/transforms.mdx b/docsite/docs/configuration/transforms/transforms.mdx index 17017013..f0955f8f 100644 --- a/docsite/docs/configuration/transforms/transforms.mdx +++ b/docsite/docs/configuration/transforms/transforms.mdx @@ -135,6 +135,7 @@ Each [**hook**](#hook) is made up of one or more **Stages**. A Stage is a self-c * The [Musicbrainz](/configuration/transforms/musicbrainz) Stage tries to match Play data with the Musicbrainz database and to standardize the Artist/Title/Album data * The [Rocksky](/configuration/transforms/rocksky) Stage tries to match Play data with the Rocksky metadata API to enrich meta IDs (like MBID, ISRC), duration, and artwork * The [Spotify](/configuration/transforms/spotify) Stage tries to match Play data with the Spotify catalog (prioritizing ISRC lookups) to standardize the Artist/Title/Album data +* The [Cover Art Archive](/configuration/transforms/coverartarchive) Stage adds album art to your scrobble based on Musicbrainz Relase/Release-Group MBID Each Stage in a Hook receives Play data from the previous Stage. diff --git a/src/backend/common/transforms/TransformerManager.ts b/src/backend/common/transforms/TransformerManager.ts index 3fe833e0..3e59109c 100644 --- a/src/backend/common/transforms/TransformerManager.ts +++ b/src/backend/common/transforms/TransformerManager.ts @@ -10,6 +10,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { nanoid } from "nanoid"; import { SimpleError, StageTransformError } from "../errors/MSErrors.ts"; import { configFromEnv as rsConfigFromEnv } from "./rocksky/RockskyTransformerUtil.ts"; +import { configFromEnv as caaConfigFromEnv } from "./coverartarchive/CoverArtArchiveTransformerUtil.ts"; import { type RockskyTransformerConfig } from "../vendor/rocksky/interfaces.ts"; import { configFromEnv as spotifyConfigFromEnv, type SpotifyTransformerConfig } from "./spotify/SpotifyTransformerUtil.ts"; import type { CovertArtArchiveTransformerConfig } from "./coverartarchive/CoverArtArchiveTransformerUtil.ts"; @@ -152,6 +153,19 @@ export default class TransformerManager { } this.logger.error(new Error('Unable to build Spotify Transformer from ENV', {cause: e})); } + try { + const caaConfig = caaConfigFromEnv(this.logger); + if(caaConfig !== undefined) { + this.addTransformerConfig(caaConfig); + } else { + this.logger.debug('No Covert Art Archive transformer to build from ENV'); + } + } catch (e) { + if(e instanceof SimpleError) { + this.logger.error(`Unable to build Cover Art Archive Transformer from ENV: ${e.message}`); + } + this.logger.error(new Error('Unable to build Cover Art Archive Transformer from ENV', {cause: e})); + } } public async initTransformers() { diff --git a/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts index 06fb8756..389c202e 100644 --- a/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts +++ b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts @@ -6,7 +6,7 @@ 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 { difference, intersect } 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"; @@ -106,9 +106,12 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer 0) { @@ -144,10 +147,10 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { const hasFields = coverImageHas(x); - if(!allowedTypes.includes('any') && difference(allowedTypes, hasFields.types).length > 0) { + if(!allowedTypes.includes('any') && intersect(allowedTypes, hasFields.types).length === 0) { return false; } - if(!allowedSizes.includes('any') && difference(allowedSizes, hasFields.sizes).length > 0) { + if(!allowedSizes.includes('any') && intersect(allowedSizes, hasFields.sizes).length === 0) { return false; } return true; @@ -223,24 +226,25 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { const hasFields = coverImageHas(x); - if(!allowedTypes.includes('any') && difference(allowedTypes, hasFields.types).length > 0) { + if(!allowedTypes.includes('any') && intersect(allowedTypes, hasFields.types).length === 0) { return false; } - if(!allowedSizes.includes('any') && difference(allowedSizes, hasFields.sizes).length > 0) { + if(!allowedSizes.includes('any') && intersect(allowedSizes, hasFields.sizes).length === 0) { return false; } return true; }); let preferred: string | undefined; - for(const p of preferredSizes) { - for(const image of validImages) { - if(image.thumbnails[p] !== undefined) { - preferred = image.thumbnails[p]; - break; + loop1: + for(const p of preferredSizes) { + for(const image of validImages) { + if(image.thumbnails[p] !== undefined) { + preferred = image.thumbnails[p]; + break loop1; + } } } - } if(preferred === undefined) { // get the first thumb from the first image preferred = Object.values(validImages[0].thumbnails)[0]; @@ -278,28 +282,21 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer; export const caaSizesConfig = z.enum([...thumbSizes.options, 'any']); export type CAASizesConfig = z.infer; @@ -27,7 +28,7 @@ export const configFromEnv = (logger: MaybeLogger = new MaybeLogger()) => { if (transformEnv !== undefined && transformEnv.trim() !== '') { tConfig = { type: 'coverartarchive', - name: 'MSCAADefault', + name: DEFAULT_TRANSFORMER_ENV_NAME, data: { apis: [ { @@ -75,11 +76,11 @@ export const hasArtFields = (play: PlayObject): 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'); - } + // 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 -- 2.51.2