diff --git a/docs/using-seerr/settings/artwork-providers.md b/docs/using-seerr/settings/artwork-providers.md new file mode 100644 index 0000000000..7792d437c5 --- /dev/null +++ b/docs/using-seerr/settings/artwork-providers.md @@ -0,0 +1,57 @@ +--- +title: Artwork Providers +description: Configure the artwork providers Seerr uses for music artists. +sidebar_position: 8 +--- + +# Artwork Providers + +Seerr fetches artist artwork from a third-party provider. The +**Settings → Metadata Providers** page exposes an **Artwork Providers +Configuration** section where administrators can tune the connection details +for each provider. + +A **Test** button at the top and bottom of the page exercises every configured +artwork provider and updates the status badge (`TheAudioDB`). The badge shows +one of: + +- **Operational** — the test request succeeded. +- **Not tested** — no test has been run yet in this session. +- **Failed** — the test request errored; a toast describes which provider + failed so the issue can be fixed without scrolling back up. + +## TheAudioDB + +[TheAudioDB](https://www.theaudiodb.com) provides artist images (thumbnail and +background) keyed by MusicBrainz artist MBID. The public API requires a key, +and TheAudioDB publishes a free test key (`195003`) suitable for low-volume +use. Patrons of the project receive a personal key with higher limits. + +:::warning +The default `195003` key is the **shared community test key** published by +TheAudioDB. It is rate-limited aggressively and may be revoked or throttled +without notice. For anything beyond casual personal use you should +[become a patron of TheAudioDB](https://www.patreon.com/theaudiodb) and +replace it with your own key. +::: + +| Field | Default | Description | +| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **API key** | `195003` | Your TheAudioDB API key. Defaults to the public test key shared by the community; supply your own from [theaudiodb.com](https://www.theaudiodb.com) for production use and higher limits. | +| **Max requests per second** | `25` | Outbound rate limit. | +| **Max concurrent requests** | `20` | Cap on in-flight requests. | + +Responses are cached for six hours. + +## Saving and Testing + +The **Save** button under "Artwork Providers Configuration" persists changes to +`config/settings.json`. The **Test** buttons at the top and bottom of the page +make a single request per provider: + +- TheAudioDB is tested with a known artist MBID + (`cc197bad-dc9c-440d-a5b5-d52ba2e14234` — Coldplay) using the currently + configured API key. + +If a test fails, a toast is shown for that provider and its status badge is +updated. diff --git a/seerr-api.yml b/seerr-api.yml index 59777277cb..a01812a497 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -529,6 +529,34 @@ components: type: string enum: [tvdb, tmdb] example: 'tvdb' + ArtworkProvidersSettings: + type: object + properties: + theAudioDb: + type: object + properties: + apiKey: + type: string + example: '195003' + maxRPS: + type: integer + minimum: 1 + example: 25 + maxRequests: + type: integer + minimum: 1 + example: 20 + ArtworkProvidersTestResponse: + type: object + properties: + success: + type: boolean + tests: + type: object + properties: + theAudioDb: + type: string + enum: ['ok', 'failed', 'not tested'] TautulliSettings: type: object properties: @@ -2723,6 +2751,61 @@ paths: message: type: string example: 'Successfully connected to TVDB' + /settings/artwork-providers: + get: + summary: Get Artwork Provider settings + description: Retrieves current settings for the TheAudioDB client used by music. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ArtworkProvidersSettings' + put: + summary: Update Artwork Provider settings + description: Updates Artwork Provider settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ArtworkProvidersSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + allOf: + - type: object + properties: + success: + type: boolean + - $ref: '#/components/schemas/ArtworkProvidersSettings' + /settings/artwork-providers/test: + post: + summary: Test Artwork Provider connectivity + description: Probes TheAudioDB using the currently saved settings. Returns per-provider status. + tags: + - settings + responses: + '200': + description: All providers responded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ArtworkProvidersTestResponse' + '500': + description: One or more providers failed to respond + content: + application/json: + schema: + $ref: '#/components/schemas/ArtworkProvidersTestResponse' /settings/tautulli: get: summary: Get Tautulli settings diff --git a/server/api/theaudiodb/index.ts b/server/api/theaudiodb/index.ts new file mode 100644 index 0000000000..8a115f2ce3 --- /dev/null +++ b/server/api/theaudiodb/index.ts @@ -0,0 +1,100 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import type { TadbArtistResponse } from './interfaces'; + +export const THE_AUDIO_DB_BASE_URL = 'https://www.theaudiodb.com/api/v1/json'; +export const THE_AUDIO_DB_DEFAULT_API_KEY = '195003'; +export const THE_AUDIO_DB_DEFAULT_MAX_RPS = 25; +export const THE_AUDIO_DB_DEFAULT_MAX_REQUESTS = 20; + +class TheAudioDb extends ExternalAPI { + // 6 hours, matching the cache's stdTtl and the public docs. + private readonly CACHE_TTL = 21600; + private readonly apiKey: string; + + constructor() { + const { theAudioDb } = getSettings().artworkProviders; + const apiKey = theAudioDb.apiKey; + const maxRPS = + theAudioDb.maxRPS > 0 ? theAudioDb.maxRPS : THE_AUDIO_DB_DEFAULT_MAX_RPS; + const maxRequests = + theAudioDb.maxRequests > 0 + ? theAudioDb.maxRequests + : THE_AUDIO_DB_DEFAULT_MAX_REQUESTS; + + super( + THE_AUDIO_DB_BASE_URL, + {}, + { + nodeCache: cacheManager.getCache('tadb').data, + rateLimit: { + maxRequests, + maxRPS, + }, + } + ); + + this.apiKey = apiKey; + } + + private createEmptyResponse() { + return { artistThumb: null, artistBackground: null }; + } + + public async getArtistImages( + id: string + ): Promise<{ artistThumb: string | null; artistBackground: string | null }> { + if (!this.apiKey) { + return this.createEmptyResponse(); + } + try { + const data = await this.get( + `/${this.apiKey}/artist-mb.php`, + { params: { i: id } }, + this.CACHE_TTL + ); + + return { + artistThumb: data.artists?.[0]?.strArtistThumb || null, + artistBackground: data.artists?.[0]?.strArtistFanart || null, + }; + } catch (error) { + logger.error('Failed to fetch artist images', { + label: 'TheAudioDb', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return this.createEmptyResponse(); + } + } + + public hasApiKey(): boolean { + return Boolean(this.apiKey); + } + + public async testConnection(): Promise { + if (!this.apiKey) { + return false; + } + try { + // Hit a known MusicBrainz artist ID (Coldplay) to verify the API key + // is accepted and the upstream responds with a parseable payload. + const data = await this.get( + `/${this.apiKey}/artist-mb.php`, + { params: { i: 'cc197bad-dc9c-440d-a5b5-d52ba2e14234' } }, + 0 + ); + return Array.isArray(data.artists); + } catch (error) { + logger.error('TheAudioDB connection test failed', { + label: 'TheAudioDb', + error: error instanceof Error ? error.message : 'Unknown error', + }); + return false; + } + } +} + +export default TheAudioDb; diff --git a/server/api/theaudiodb/interfaces.ts b/server/api/theaudiodb/interfaces.ts new file mode 100644 index 0000000000..e32c1694f0 --- /dev/null +++ b/server/api/theaudiodb/interfaces.ts @@ -0,0 +1,8 @@ +interface TadbArtist { + strArtistThumb: string | null; + strArtistFanart: string | null; +} + +export interface TadbArtistResponse { + artists?: TadbArtist[]; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index c92a648cdf..eb5accef40 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -2,6 +2,7 @@ import NodeCache from 'node-cache'; export type AvailableCacheIds = | 'tmdb' + | 'tadb' | 'radarr' | 'sonarr' | 'rt' @@ -49,6 +50,10 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), + tadb: new Cache('tadb', 'The Audio Database API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), lidarr: new Cache('lidarr', 'Lidarr API'), diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index ddee14de75..f0486cd28b 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -123,6 +123,16 @@ export interface MetadataSettings { anime: MetadataProviderType; } +export interface TheAudioDbSettings { + apiKey: string; + maxRPS: number; + maxRequests: number; +} + +export interface ArtworkProvidersSettings { + theAudioDb: TheAudioDbSettings; +} + export interface ProxySettings { enabled: boolean; hostname: string; @@ -390,6 +400,7 @@ export interface AllSettings { jobs: Record; network: NetworkSettings; metadataSettings: MetadataSettings; + artworkProviders: ArtworkProvidersSettings; migrations: string[]; } @@ -459,6 +470,13 @@ class Settings { tv: MetadataProviderType.TMDB, anime: MetadataProviderType.TMDB, }, + artworkProviders: { + theAudioDb: { + apiKey: '195003', + maxRPS: 25, + maxRequests: 20, + }, + }, radarr: [], sonarr: [], lidarr: [], @@ -684,6 +702,17 @@ class Settings { ); } + get artworkProviders(): ArtworkProvidersSettings { + return this.data.artworkProviders; + } + + set artworkProviders(data: ArtworkProvidersSettings) { + this.data.artworkProviders = mergeSettings( + this.data.artworkProviders, + data + ); + } + get radarr(): RadarrSettings[] { return this.data.radarr; } diff --git a/server/routes/settings/artworkProviders.ts b/server/routes/settings/artworkProviders.ts new file mode 100644 index 0000000000..db9ba371f1 --- /dev/null +++ b/server/routes/settings/artworkProviders.ts @@ -0,0 +1,99 @@ +import TheAudioDb, { + THE_AUDIO_DB_DEFAULT_MAX_REQUESTS, + THE_AUDIO_DB_DEFAULT_MAX_RPS, +} from '@server/api/theaudiodb'; +import { + getSettings, + type ArtworkProvidersSettings, +} from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +function getTestResultString(testValue: number): string { + // -1: never started; 2: skipped (no API key) — both surface as "not tested" + // to stay within the documented ['ok', 'failed', 'not tested'] enum. + if (testValue === -1 || testValue === 2) return 'not tested'; + if (testValue === 0) return 'failed'; + return 'ok'; +} + +function coerceNumber(value: unknown, fallback: number, min = 1): number { + const num = Number(value); + if (!Number.isFinite(num) || num < min) return fallback; + return Math.floor(num); +} + +function applyArtworkProvidersDefaults( + settings: ArtworkProvidersSettings +): ArtworkProvidersSettings { + return { + theAudioDb: { + apiKey: settings.theAudioDb.apiKey ?? '', + maxRPS: coerceNumber( + settings.theAudioDb.maxRPS, + THE_AUDIO_DB_DEFAULT_MAX_RPS + ), + maxRequests: coerceNumber( + settings.theAudioDb.maxRequests, + THE_AUDIO_DB_DEFAULT_MAX_REQUESTS + ), + }, + }; +} + +const artworkProvidersRoutes = Router(); + +artworkProvidersRoutes.get('/', (_req, res) => { + const { artworkProviders } = getSettings(); + res.status(200).json(applyArtworkProvidersDefaults(artworkProviders)); +}); + +artworkProvidersRoutes.put('/', async (req, res) => { + const settings = getSettings(); + const body = req.body as Partial; + + const current = settings.artworkProviders; + const merged: ArtworkProvidersSettings = { + theAudioDb: { + ...current.theAudioDb, + ...(body.theAudioDb ?? {}), + }, + }; + + const updated = applyArtworkProvidersDefaults(merged); + + settings.artworkProviders = updated; + await settings.save(); + + res.status(200).json({ success: true, ...updated }); +}); + +artworkProvidersRoutes.post('/test', async (_req, res) => { + let tadbTest = -1; + + try { + tadbTest = 0; + const tadb = new TheAudioDb(); + if (!tadb.hasApiKey()) { + tadbTest = 2; + } else { + tadbTest = (await tadb.testConnection()) ? 1 : 0; + } + } catch (e) { + logger.error('Failed to test TheAudioDB', { + label: 'ArtworkProviders', + message: e instanceof Error ? e.message : 'Unknown error', + }); + } + + const success = tadbTest === 1 || tadbTest === 2; + + return res.status(success ? 200 : 500).json({ + success, + tests: { + theAudioDb: getTestResultString(tadbTest), + }, + }); +}); + +export default artworkProvidersRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 459076c99e..77de132617 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -40,6 +40,7 @@ import path from 'path'; import semver from 'semver'; import { URL } from 'url'; import lidarrRoutes from './lidarr'; +import artworkProvidersRoutes from './artworkProviders'; import metadataRoutes from './metadata'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; @@ -53,6 +54,7 @@ settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/lidarr', lidarrRoutes); settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/metadatas', metadataRoutes); +settingsRoutes.use('/artwork-providers', artworkProvidersRoutes); const filteredMainSettings = ( user: User, diff --git a/src/components/Settings/SettingsMetadata.tsx b/src/components/Settings/SettingsMetadata.tsx index d11bece041..7cba62f210 100644 --- a/src/components/Settings/SettingsMetadata.tsx +++ b/src/components/Settings/SettingsMetadata.tsx @@ -10,7 +10,7 @@ import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import axios from 'axios'; -import { Form, Formik } from 'formik'; +import { Field, Form, Formik } from 'formik'; import { useState } from 'react'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; @@ -34,11 +34,22 @@ const messages = defineMessages('components.Settings', { 'TMDB provider does not work, please select another metadata provider', tvdbProviderDoesnotWork: 'TVDB provider does not work, please select another metadata provider', + theAudioDbProviderDoesnotWork: + 'TheAudioDB did not respond — check the Artwork Providers Configuration', allChosenProvidersAreOperational: 'All chosen metadata providers are operational', connectionTestFailed: 'Connection test failed', failedToSaveMetadataSettings: 'Failed to save metadata provider settings', metadataSettingsSaved: 'Metadata provider settings saved', + artworkProvidersConfiguration: 'Artwork Providers Configuration', + artworkProvidersConfigurationDescription: + 'Configure rate limits and credentials for the artist-image provider used by music. The upstream is a hosted service with no self-hosted equivalent, so only the request rate and the API key are configurable.', + theAudioDb: 'TheAudioDB', + maxRPS: 'Max requests per second', + maxRequests: 'Max in-flight requests', + apiKey: 'API key (required)', + artworkProvidersSaved: 'Artwork provider settings saved', + artworkProvidersSaveFailed: 'Failed to save artwork provider settings', }); type ProviderStatus = 'ok' | 'not tested' | 'failed'; @@ -46,6 +57,7 @@ type ProviderStatus = 'ok' | 'not tested' | 'failed'; interface ProviderResponse { tvdb: ProviderStatus; tmdb: ProviderStatus; + theAudioDb: ProviderStatus; } interface MetadataValues { @@ -57,6 +69,22 @@ interface MetadataSettings { metadata: MetadataValues; } +interface TheAudioDbSettings { + apiKey: string; + maxRPS: number; + maxRequests: number; +} + +interface ArtworkProvidersSettings { + theAudioDb: TheAudioDbSettings; +} + +const mapStatusValue = (status: string): ProviderStatus => { + if (status === 'ok') return 'ok'; + if (status === 'failed') return 'failed'; + return 'not tested'; +}; + const SettingsMetadata = () => { const intl = useIntl(); const { addToast } = useToasts(); @@ -64,6 +92,7 @@ const SettingsMetadata = () => { const defaultStatus: ProviderResponse = { tmdb: 'not tested', tvdb: 'not tested', + theAudioDb: 'not tested', }; const [providerStatus, setProviderStatus] = @@ -86,6 +115,9 @@ const SettingsMetadata = () => { } ); + const { data: artworkData, mutate: mutateArtwork } = + useSWR('/api/v1/settings/artwork-providers'); + const testConnection = async ( values: MetadataValues ): Promise => { @@ -96,46 +128,54 @@ const SettingsMetadata = () => { values.tv === MetadataProviderType.TVDB || values.anime === MetadataProviderType.TVDB; - const testData = { - tmdb: useTmdb, - tvdb: useTvdb, - }; - - try { - const response = await axios.post<{ + const tvdbTmdbPromise = axios + .post<{ success: boolean; - tests: ProviderResponse; - }>('/api/v1/settings/metadatas/test', testData); - - const newStatus: ProviderResponse = { - tmdb: useTmdb ? response.data.tests.tmdb : 'not tested', - tvdb: useTvdb ? response.data.tests.tvdb : 'not tested', - }; + tests: { tvdb: ProviderStatus; tmdb: ProviderStatus }; + }>('/api/v1/settings/metadatas/test', { tmdb: useTmdb, tvdb: useTvdb }) + .then((r) => r.data.tests) + .catch((e) => { + if (axios.isAxiosError(e) && e.response?.data?.tests) { + return e.response.data.tests as { + tvdb: ProviderStatus; + tmdb: ProviderStatus; + }; + } + return { tvdb: 'failed' as const, tmdb: 'failed' as const }; + }); - setProviderStatus(newStatus); - return newStatus; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - // If we receive an error response with a valid format - const errorData = error.response.data as { - success: boolean; - tests: ProviderResponse; + const artworkPromise = axios + .post<{ + success: boolean; + tests: { + theAudioDb: ProviderStatus; }; - - if (errorData.tests) { - const newStatus: ProviderResponse = { - tmdb: useTmdb ? errorData.tests.tmdb : 'not tested', - tvdb: useTvdb ? errorData.tests.tvdb : 'not tested', + }>('/api/v1/settings/artwork-providers/test') + .then((r) => r.data.tests) + .catch((e) => { + if (axios.isAxiosError(e) && e.response?.data?.tests) { + return e.response.data.tests as { + theAudioDb: ProviderStatus; }; - - setProviderStatus(newStatus); - return newStatus; } - } + return { + theAudioDb: 'failed' as const, + }; + }); - // In case of error without usable data - throw new Error('Failed to test connection', { cause: error }); - } + const [tvdbTmdb, artwork] = await Promise.all([ + tvdbTmdbPromise, + artworkPromise, + ]); + + const newStatus: ProviderResponse = { + tmdb: useTmdb ? mapStatusValue(tvdbTmdb.tmdb) : 'not tested', + tvdb: useTvdb ? mapStatusValue(tvdbTmdb.tvdb) : 'not tested', + theAudioDb: mapStatusValue(artwork.theAudioDb), + }; + + setProviderStatus(newStatus); + return newStatus; }; const saveSettings = async ( @@ -157,16 +197,11 @@ const SettingsMetadata = () => { // Update metadata provider status if available if (response.data.tests) { - const mapStatusValue = (status: string): ProviderStatus => { - if (status === 'ok') return 'ok'; - if (status === 'failed') return 'failed'; - return 'not tested'; - }; - - setProviderStatus({ - tmdb: mapStatusValue(response.data.tests.tmdb), - tvdb: mapStatusValue(response.data.tests.tvdb), - }); + setProviderStatus((prev) => ({ + ...prev, + tmdb: mapStatusValue(response.data.tests!.tmdb), + tvdb: mapStatusValue(response.data.tests!.tvdb), + })); } // Adapt the response to the format expected by the component @@ -189,17 +224,11 @@ const SettingsMetadata = () => { // If test data is available in the error response if (errorData.tests) { - const mapStatusValue = (status: string): ProviderStatus => { - if (status === 'ok') return 'ok'; - if (status === 'failed') return 'failed'; - return 'not tested'; - }; - - // Update metadata provider status with error data - setProviderStatus({ - tmdb: mapStatusValue(errorData.tests.tmdb), - tvdb: mapStatusValue(errorData.tests.tvdb), - }); + setProviderStatus((prev) => ({ + ...prev, + tmdb: mapStatusValue(errorData.tests!.tmdb), + tvdb: mapStatusValue(errorData.tests!.tvdb), + })); } } @@ -250,6 +279,104 @@ const SettingsMetadata = () => { } }; + const runProviderTests = async ( + metadataValues: MetadataValues + ): Promise => { + setIsTesting(true); + try { + const resp = await testConnection(metadataValues); + + const failures: string[] = []; + if (resp.tvdb === 'failed') { + failures.push(intl.formatMessage(messages.tvdbProviderDoesnotWork)); + } + if (resp.tmdb === 'failed') { + failures.push(intl.formatMessage(messages.tmdbProviderDoesnotWork)); + } + if (resp.theAudioDb === 'failed') { + failures.push( + intl.formatMessage(messages.theAudioDbProviderDoesnotWork) + ); + } + + if (failures.length > 0) { + for (const msg of failures) { + addToast(msg, { appearance: 'error', autoDismiss: true }); + } + } else { + addToast( + intl.formatMessage(messages.allChosenProvidersAreOperational), + { appearance: 'success', autoDismiss: true } + ); + } + } catch { + addToast(intl.formatMessage(messages.connectionTestFailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsTesting(false); + } + }; + + const runArtworkProviderTests = async (): Promise => { + setIsTesting(true); + try { + const resp = await axios + .post<{ + success: boolean; + tests: { + theAudioDb: ProviderStatus; + }; + }>('/api/v1/settings/artwork-providers/test') + .then((r) => r.data.tests) + .catch((e) => { + if (axios.isAxiosError(e) && e.response?.data?.tests) { + return e.response.data.tests as { + theAudioDb: ProviderStatus; + }; + } + return { + theAudioDb: 'failed' as const, + }; + }); + + const mapped = { + theAudioDb: mapStatusValue(resp.theAudioDb), + }; + + setProviderStatus((prev) => ({ + ...prev, + theAudioDb: mapped.theAudioDb, + })); + + const failures: string[] = []; + if (mapped.theAudioDb === 'failed') { + failures.push( + intl.formatMessage(messages.theAudioDbProviderDoesnotWork) + ); + } + + if (failures.length > 0) { + for (const msg of failures) { + addToast(msg, { appearance: 'error', autoDismiss: true }); + } + } else { + addToast( + intl.formatMessage(messages.allChosenProvidersAreOperational), + { appearance: 'success', autoDismiss: true } + ); + } + } catch { + addToast(intl.formatMessage(messages.connectionTestFailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsTesting(false); + } + }; + if (!data && !error) { return ; } @@ -283,7 +410,7 @@ const SettingsMetadata = () => {
- TheMovieDB: + TheMovieDB: {
- TheTVDB: + TheTVDB: {
+
+ TheAudioDB: + + + {getStatusMessage(providerStatus.theAudioDb)} + + +
@@ -391,55 +529,8 @@ const SettingsMetadata = () => { + + + + + + + + )} + + ); }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 14311df766..48c885f9f6 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1208,7 +1208,11 @@ "components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality", "components.Settings.allChosenProvidersAreOperational": "All chosen metadata providers are operational", "components.Settings.animeMetadataProvider": "Anime metadata provider", - "components.Settings.apiKey": "API key", + "components.Settings.apiKey": "API key (required)", + "components.Settings.artworkProvidersConfiguration": "Artwork Providers Configuration", + "components.Settings.artworkProvidersConfigurationDescription": "Configure rate limits and credentials for the artist-image provider used by music. The upstream is a hosted service with no self-hosted equivalent, so only the request rate and the API key are configurable.", + "components.Settings.artworkProvidersSaveFailed": "Failed to save artwork provider settings", + "components.Settings.artworkProvidersSaved": "Artwork provider settings saved", "components.Settings.blocklistedTagImportInstructions": "Paste blocklist tag configuration below.", "components.Settings.blocklistedTagImportTitle": "Import Blocklisted Tag Configuration", "components.Settings.blocklistedTagsText": "Blocklisted Tags", @@ -1255,6 +1259,8 @@ "components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Seerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", "components.Settings.manualscanDescriptionJellyfin": "Normally, this will only be run once every 24 hours. Seerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Seerr, a one-time full manual library scan is recommended!", "components.Settings.manualscanJellyfin": "Manual Library Scan", + "components.Settings.maxRPS": "Max requests per second", + "components.Settings.maxRequests": "Max in-flight requests", "components.Settings.mediaTypeMovie": "movie", "components.Settings.mediaTypeMusic": "music", "components.Settings.mediaTypeSeries": "series", @@ -1323,6 +1329,8 @@ "components.Settings.tautulliApiKey": "API Key", "components.Settings.tautulliSettings": "Tautulli Settings", "components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Seerr fetches watch history data for your Plex media from Tautulli.", + "components.Settings.theAudioDb": "TheAudioDB", + "components.Settings.theAudioDbProviderDoesnotWork": "TheAudioDB did not respond — check the Artwork Providers Configuration", "components.Settings.timeout": "Timeout", "components.Settings.tip": "Tip", "components.Settings.tmdbProviderDoesnotWork": "TMDB provider does not work, please select another metadata provider",