docs: Add Cover Art Archive guidance and fix some bugs/usage for it

This commit is contained in:
FoxxMD 2026-09-26 15:19:27 -04:00
parent 00075601e4
commit a2580583da
5 changed files with 592 additions and 30 deletions

View file

@ -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"`.
<details>
<summary>Non-Default Servers and Rate Limiting</summary>
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"
}
]
},
}
]
}
```
<h3>Rate Limiting</h3>
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
}
}
]
},
}
]
}
```
</details>
### 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`.
<details>
<summary>Example</summary>
```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
}
}
]
}
}
}
]
```
</details>
:::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.
<details>
<summary>Example</summary>
```json5 title="discord.json"
[
{
"name": "MyDiscord",
"data": { /* ... */},
"options": {
"playTransform": {
"preCompare": [
{
"type": "coverartarchive",
"name": "MSDefault",
"allowedTypes": ["any"], // override from defaults
}
]
}
}
}
]
```
</details>
:::
## 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`
:::
<details>
<summary>Example Full Docker Deploy with ENV Configuration</summary>
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
```
</details>
## 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
<details>
<summary>Example</summary>
[Stage Configuration](/configuration/transforms#configuring-stages) example:
```json5
// ...
"defaults": {
// uncomment to make the stage always search, even if album art is present
//"forceSearch": true
}
```
</details>
### 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.
<details>
<summary>Example</summary>
```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
}
]
}
}
}
]
```
</details>
## Examples
### Minimal
<details>
<summary>Example</summary>
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
```
</details>
### 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.
<details>
<summary>Example</summary>
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
Using the [ENV Configuration](#env-configuration) from above, add these fields to your docker compose `environment:`
```yaml
- CAA_PRESETS=any
- DISCORD_TRANSFORMS=musicbrainz,coverartarchive
```
</TabItem>
<TabItem value="file" label="File">
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
}
]
}
}
}
]
```
</TabItem>
<TabItem value="aio" label="AIO">
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
}
]
}
}
}
]
}
```
</TabItem>
</Tabs>
</details>

View file

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

View file

@ -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() {

View file

@ -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<E
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});
if(!forceSearch) {
throw new SkipTransformStageError(`At least one art field (${found.join(',')}) already exists`, {shortStack: true});
}
this.logger.debug('Play has at least one art field but forceSearch=true so will still search for art');
}
this.logger.debug('Play has no art fields');
this.logger.debug('Play has no art fields');
} else {
const missing = difference(searchWhenMissing, found);
if(missing.length > 0) {
@ -144,10 +147,10 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer<E
}
const meetsRequirements = results.images.some(x => {
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<E
const validImages = transformData.images.filter(x => {
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<E
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');
this.logger.debug('When condition for art not met, returning original art');
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
album: uri
}
}

View file

@ -2,8 +2,9 @@ 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";
import { DEFAULT_TRANSFORMER_ENV_NAME } from "../../../../core/Transform.ts";
export const caaMissingTypes = z.enum(['album','track','artist']);
export const caaMissingTypes = z.enum(['album']);
export type CAAMissingType = z.infer<typeof caaMissingTypes>;
export const caaSizesConfig = z.enum([...thumbSizes.options, 'any']);
export type CAASizesConfig = z.infer<typeof caaSizesConfig>;
@ -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;
}