From 25de564c694a209d47a2670aa00ffb16ff773c3c Mon Sep 17 00:00:00 2001 From: Utkarsh Verma Date: Sun, 19 Jul 2026 10:03:53 +0200 Subject: [PATCH 1/3] tidy: format codebase --- cmd/api/main.go | 2 +- internal/db/sqlite/exports.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index b6cb52ca..dc7119f4 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,9 +2,9 @@ package main import ( "fmt" + "log" "os" "strings" - "log" "github.com/gabehf/koito/engine" ) diff --git a/internal/db/sqlite/exports.go b/internal/db/sqlite/exports.go index d4b95320..f6cc9492 100644 --- a/internal/db/sqlite/exports.go +++ b/internal/db/sqlite/exports.go @@ -83,4 +83,3 @@ func (s *Sqlite) GetExportPage(ctx context.Context, opts db.GetExportPageOpts) ( } return items, nil } - From 12f07bc584fa499508409ebb7c60bbb986b3d02a Mon Sep 17 00:00:00 2001 From: Utkarsh Verma Date: Sun, 19 Jul 2026 10:05:44 +0200 Subject: [PATCH 2/3] misc: add nix shell for reproducible dev environments --- shell.nix | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 shell.nix diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..125ad5a5 --- /dev/null +++ b/shell.nix @@ -0,0 +1,12 @@ +{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-26.05.tar.gz") { } }: + +pkgs.mkShell { + packages = with pkgs; [ + go_1_25 + gnumake + pkg-config + vips + ]; + + CGO_ENABLED = "1"; +} From fc1fc9fdefe52ddb09034983bff7605d6c90445a Mon Sep 17 00:00:00 2001 From: Utkarsh Verma Date: Sun, 19 Jul 2026 10:37:59 +0200 Subject: [PATCH 3/3] feat: add jellyfin image source (#327) --- docs/astro.config.mjs | 1 + docs/src/content/docs/quickstart/jellyfin.md | 39 +++ .../content/docs/reference/configuration.md | 19 ++ engine/engine.go | 23 ++ internal/cfg/cfg.go | 18 +- internal/cfg/getters.go | 24 ++ internal/images/imagesrc.go | 46 +++- internal/images/jellyfin.go | 260 ++++++++++++++++++ 8 files changed, 418 insertions(+), 12 deletions(-) create mode 100644 docs/src/content/docs/quickstart/jellyfin.md create mode 100644 internal/images/jellyfin.go diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 78750166..07da993a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -45,6 +45,7 @@ export default defineConfig({ label: "Quickstart", items: [ { label: "Setup with Navidrome", slug: "quickstart/navidrome" }, + { label: "Setup with Jellyfin", slug: "quickstart/jellyfin" }, ], }, { diff --git a/docs/src/content/docs/quickstart/jellyfin.md b/docs/src/content/docs/quickstart/jellyfin.md new file mode 100644 index 00000000..511abd11 --- /dev/null +++ b/docs/src/content/docs/quickstart/jellyfin.md @@ -0,0 +1,39 @@ +--- +title: Jellyfin Quickstart +description: How to set up Koito to work with your Jellyfin instance. +--- + +## Configure Koito + +Point Koito at Jellyfin so album and artist images are fetched from your library. + +```yaml title="compose.yaml" +services: + koito: + image: gabehf/koito:latest + container_name: koito + environment: + - KOITO_JELLYFIN_URL=https://jellyfin.mydomain.com + - KOITO_JELLYFIN_API_KEY= + # Optional if Items searches return nothing with the API key alone: + # - KOITO_JELLYFIN_USER_ID= + ports: + - "4110:4110" + volumes: + - ./koito:/etc/koito + restart: unless-stopped +``` + +Create an API key in Jellyfin under **Dashboard → API Keys**. + +:::note +Koito uses SQLite for all new installations as of `v0.2.1`. If you are running a version older than that, you will need to set `KOITO_SQLITE_ENABLED=true`. +::: + +## Configure Jellyfin scrobbling + +Install the [ListenBrainz plugin for Jellyfin](https://github.com/lyarenei/jellyfin-plugin-listenbrainz) and set the ListenBrainz base URL to your Koito instance: + +`{your_koito_url}/apis/listenbrainz/1` + +Use an API key from the Koito UI as the ListenBrainz token. Listens will be stored in Koito; images for new artists/albums will be resolved from Jellyfin when possible. diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 4b28ad44..9c9fd911 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -117,6 +117,25 @@ If the environment variable is defined without **and** with the suffix at the sa changing these parameters, check the logs! ::: +##### KOITO_JELLYFIN_URL + +- Required: `true` if KOITO_JELLYFIN_API_KEY is set +- Description: The URL of your Jellyfin server. For example, `https://jellyfin.mydomain.com`. When set, Jellyfin is tried first for album and artist images (before Subsonic, Cover Art Archive, Last.fm, and Deezer). + +##### KOITO_JELLYFIN_API_KEY + +- Required: `true` if KOITO_JELLYFIN_URL is set +- Description: A Jellyfin API key (Dashboard → API Keys) used to search the library and fetch Primary images. + :::caution + If Koito is unable to validate your Jellyfin configuration, it will fail to start. If you notice your container isn't running after + changing these parameters, check the logs! + ::: + +##### KOITO_JELLYFIN_USER_ID + +- Required: `false` +- Description: Optional Jellyfin user ID. When set, item searches use `/Users/{id}/Items` instead of `/Items`. Use this if library queries with only an API key return empty results. + ##### KOITO_LASTFM_API_KEY - Required: `false` diff --git a/engine/engine.go b/engine/engine.go index fa02e021..6cbbe37d 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -187,11 +187,34 @@ func Run( } } + if cfg.JellyfinEnabled() { + l.Debug().Msg("Engine: Checking Jellyfin configuration") + req, err := http.NewRequest(http.MethodGet, cfg.JellyfinUrl()+"/System/Info", nil) + if err != nil { + l.Fatal().Err(err).Msg("Engine: Failed to build Jellyfin ping request") + } + req.Header.Set("X-Emby-Token", cfg.JellyfinApiKey()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + l.Fatal().Err(err).Msg("Engine: Failed to contact Jellyfin server! Ensure the provided URL is correct") + } else { + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + l.Fatal().Msg("Engine: Provided Jellyfin API key is invalid") + } else if resp.StatusCode < 200 || resp.StatusCode >= 300 { + l.Fatal().Int("status", resp.StatusCode).Msg("Engine: Unexpected response from Jellyfin") + } else { + l.Info().Msg("Engine: Jellyfin credentials validated successfully") + } + } + } + l.Debug().Msg("Engine: Initializing image sources") images.Initialize(images.ImageSourceOpts{ UserAgent: cfg.UserAgent(), EnableCAA: !cfg.CoverArtArchiveDisabled(), EnableDeezer: !cfg.DeezerDisabled(), + EnableJellyfin: cfg.JellyfinEnabled(), EnableSubsonic: cfg.SubsonicEnabled(), EnableLastFM: cfg.LastFMApiKey() != "", }) diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 57353e96..f85a1cf9 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -38,6 +38,9 @@ const ( DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL" SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS" + JELLYFIN_URL_ENV = "KOITO_JELLYFIN_URL" + JELLYFIN_API_KEY_ENV = "KOITO_JELLYFIN_API_KEY" + JELLYFIN_USER_ID_ENV = "KOITO_JELLYFIN_USER_ID" LASTFM_API_KEY_ENV = "KOITO_LASTFM_API_KEY" SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" @@ -74,8 +77,12 @@ type config struct { disableMusicBrainz bool subsonicUrl string subsonicParams string - lastfmApiKey string subsonicEnabled bool + jellyfinUrl string + jellyfinApiKey string + jellyfinUserID string + jellyfinEnabled bool + lastfmApiKey string skipImport bool fetchImageDuringImport bool allowedHosts []string @@ -171,9 +178,16 @@ func loadConfig(getenv func(string) string, version string) (*config, error) { cfg.subsonicUrl = getenv(SUBSONIC_URL_ENV) cfg.subsonicParams = getenv(SUBSONIC_PARAMS_ENV) cfg.subsonicEnabled = cfg.subsonicUrl != "" && cfg.subsonicParams != "" - if cfg.subsonicEnabled && (cfg.subsonicUrl == "" || cfg.subsonicParams == "") { + if (cfg.subsonicUrl != "") != (cfg.subsonicParams != "") { return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use subsonic image fetching", SUBSONIC_URL_ENV, SUBSONIC_PARAMS_ENV) } + cfg.jellyfinUrl = strings.TrimRight(getenv(JELLYFIN_URL_ENV), "/") + cfg.jellyfinApiKey = getenv(JELLYFIN_API_KEY_ENV) + cfg.jellyfinUserID = getenv(JELLYFIN_USER_ID_ENV) + cfg.jellyfinEnabled = cfg.jellyfinUrl != "" && cfg.jellyfinApiKey != "" + if (cfg.jellyfinUrl != "") != (cfg.jellyfinApiKey != "") { + return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use jellyfin image fetching", JELLYFIN_URL_ENV, JELLYFIN_API_KEY_ENV) + } cfg.lastfmApiKey = getenv(LASTFM_API_KEY_ENV) cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV)) diff --git a/internal/cfg/getters.go b/internal/cfg/getters.go index b095ea68..96518689 100644 --- a/internal/cfg/getters.go +++ b/internal/cfg/getters.go @@ -132,6 +132,30 @@ func SubsonicParams() string { return globalConfig.subsonicParams } +func JellyfinEnabled() bool { + lock.RLock() + defer lock.RUnlock() + return globalConfig.jellyfinEnabled +} + +func JellyfinUrl() string { + lock.RLock() + defer lock.RUnlock() + return globalConfig.jellyfinUrl +} + +func JellyfinApiKey() string { + lock.RLock() + defer lock.RUnlock() + return globalConfig.jellyfinApiKey +} + +func JellyfinUserID() string { + lock.RLock() + defer lock.RUnlock() + return globalConfig.jellyfinUserID +} + func LastFMApiKey() string { lock.RLock() defer lock.RUnlock() diff --git a/internal/images/imagesrc.go b/internal/images/imagesrc.go index 531407bd..dc43647c 100644 --- a/internal/images/imagesrc.go +++ b/internal/images/imagesrc.go @@ -15,16 +15,20 @@ import ( type ImageSource struct { deezerEnabled bool deezerC *DeezerClient + jellyfinEnabled bool + jellyfinC *JellyfinClient subsonicEnabled bool subsonicC *SubsonicClient lastfmEnabled bool lastfmC *LastFMClient caaEnabled bool } + type ImageSourceOpts struct { UserAgent string EnableCAA bool EnableDeezer bool + EnableJellyfin bool EnableSubsonic bool EnableLastFM bool } @@ -56,6 +60,10 @@ func Initialize(opts ImageSourceOpts) { imgsrc.deezerEnabled = true imgsrc.deezerC = NewDeezerClient() } + if opts.EnableJellyfin { + imgsrc.jellyfinEnabled = true + imgsrc.jellyfinC = NewJellyfinClient() + } if opts.EnableSubsonic { imgsrc.subsonicEnabled = true imgsrc.subsonicC = NewSubsonicClient() @@ -68,15 +76,25 @@ func Initialize(opts ImageSourceOpts) { } func Shutdown() { - imgsrc.deezerC.Shutdown() + if imgsrc.deezerC != nil { + imgsrc.deezerC.Shutdown() + } } func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) { l := logger.FromContext(ctx) - if !imgsrc.deezerEnabled && !imgsrc.subsonicEnabled && !imgsrc.lastfmEnabled { + if !imgsrc.deezerEnabled && !imgsrc.jellyfinEnabled && !imgsrc.subsonicEnabled && !imgsrc.lastfmEnabled { l.Warn().Msg("GetArtistImage: No image providers are enabled") return "", nil } + if imgsrc.jellyfinEnabled { + img, err := imgsrc.jellyfinC.GetArtistImage(ctx, opts.MBID, opts.Aliases[0]) + if err != nil { + l.Debug().Err(err).Msg("GetArtistImage: Could not find artist image from Jellyfin") + } else if img != "" { + return img, nil + } + } if imgsrc.subsonicEnabled { img, err := imgsrc.subsonicC.GetArtistImage(ctx, opts.MBID, opts.Aliases[0]) if err != nil { @@ -84,8 +102,6 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) { } else if img != "" { return img, nil } - } else { - l.Debug().Msg("GetArtistImage: Subsonic image fetching is disabled") } if imgsrc.deezerEnabled { img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases) @@ -112,10 +128,20 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) { func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { l := logger.FromContext(ctx) + if imgsrc.jellyfinEnabled { + img, err := imgsrc.jellyfinC.GetAlbumImage(ctx, opts.ReleaseMbzID, opts.Artists[0], opts.Album) + if err != nil { + l.Debug().Err(err).Msg("GetAlbumImage: Could not find album image from Jellyfin") + } + if img != "" { + return img, nil + } + l.Debug().Msg("Could not find album cover from Jellyfin") + } if imgsrc.subsonicEnabled { img, err := imgsrc.subsonicC.GetAlbumImage(ctx, opts.ReleaseMbzID, opts.Artists[0], opts.Album) if err != nil { - l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Subsonic") + l.Debug().Err(err).Msg("GetAlbumImage: Could not find album image from Subsonic") } if img != "" { return img, nil @@ -128,7 +154,7 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { url := fmt.Sprintf(caaBaseUrl+"/release/%s/front", opts.ReleaseMbzID.String()) resp, err := http.DefaultClient.Head(url) if err != nil { - l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from CoverArtArchive with Release MBID") + l.Debug().Err(err).Msg("GetAlbumImage: Could not find album image from CoverArtArchive with Release MBID") } else { if resp.StatusCode == 200 { return url, nil @@ -141,7 +167,7 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { url := fmt.Sprintf(caaBaseUrl+"/release-group/%s/front", opts.ReleaseGroupMbzID.String()) resp, err := http.DefaultClient.Head(url) if err != nil { - l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from CoverArtArchive with Release Group MBID") + l.Debug().Err(err).Msg("GetAlbumImage: Could not find album image from CoverArtArchive with Release Group MBID") } if resp.StatusCode == 200 { return url, nil @@ -151,18 +177,18 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { if imgsrc.lastfmEnabled { img, err := imgsrc.lastfmC.GetAlbumImage(ctx, opts.ReleaseMbzID, opts.Artists[0], opts.Album) if err != nil { - l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Subsonic") + l.Debug().Err(err).Msg("GetAlbumImage: Could not find album image from LastFM") } if img != "" { return img, nil } - l.Debug().Msg("Could not find album cover from Subsonic") + l.Debug().Msg("Could not find album cover from LastFM") } if imgsrc.deezerEnabled { l.Debug().Msg("Attempting to find album image from Deezer") img, err := imgsrc.deezerC.GetAlbumImages(ctx, opts.Artists, opts.Album) if err != nil { - l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Deezer") + l.Debug().Err(err).Msg("GetAlbumImage: Could not find album image from Deezer") return "", err } return img, nil diff --git a/internal/images/jellyfin.go b/internal/images/jellyfin.go new file mode 100644 index 00000000..07556623 --- /dev/null +++ b/internal/images/jellyfin.go @@ -0,0 +1,260 @@ +package images + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gabehf/koito/internal/cfg" + "github.com/gabehf/koito/internal/logger" + "github.com/gabehf/koito/queue" + "github.com/google/uuid" +) + +type JellyfinClient struct { + url string + apiKey string + userID string + userAgent string + requestQueue *queue.RequestQueue +} + +type jellyfinItemsResponse struct { + Items []jellyfinItem `json:"Items"` +} + +type jellyfinItem struct { + ID string `json:"Id"` + Name string `json:"Name"` + AlbumArtist string `json:"AlbumArtist"` + ImageTags map[string]string `json:"ImageTags"` + ProviderIds map[string]string `json:"ProviderIds"` +} + +func NewJellyfinClient() *JellyfinClient { + return &JellyfinClient{ + url: cfg.JellyfinUrl(), + apiKey: cfg.JellyfinApiKey(), + userID: cfg.JellyfinUserID(), + userAgent: cfg.UserAgent(), + requestQueue: queue.NewRequestQueue(5, 5), + } +} + +func (c *JellyfinClient) queue(ctx context.Context, req *http.Request) ([]byte, error) { + l := logger.FromContext(ctx) + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Emby-Token", c.apiKey) + + resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) { + resp, err := client.Do(req) + if err != nil { + l.Debug().Err(err).Str("url", req.URL.String()).Msg("Failed to contact Jellyfin") + done <- queue.RequestResult{Err: err} + return + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + done <- queue.RequestResult{Err: fmt.Errorf("received non-ok status from Jellyfin: %s", resp.Status)} + return + } + body, err := io.ReadAll(resp.Body) + done <- queue.RequestResult{Body: body, Err: err} + }) + + result := <-resultChan + return result.Body, result.Err +} + +func (c *JellyfinClient) itemsPath() string { + if c.userID != "" { + return "/Users/" + url.PathEscape(c.userID) + "/Items" + } + return "/Items" +} + +func (c *JellyfinClient) searchItems(ctx context.Context, itemType, searchTerm string) ([]jellyfinItem, error) { + l := logger.FromContext(ctx) + q := url.Values{} + q.Set("Recursive", "true") + q.Set("IncludeItemTypes", itemType) + q.Set("SearchTerm", searchTerm) + q.Set("Fields", "ProviderIds") + q.Set("EnableImageTypes", "Primary") + q.Set("ImageTypeLimit", "1") + q.Set("Limit", "25") + + endpoint := c.url + c.itemsPath() + "?" + q.Encode() + l.Debug().Msgf("Sending request to Jellyfin: GET %s", endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("searchItems: %w", err) + } + body, err := c.queue(ctx, req) + if err != nil { + return nil, fmt.Errorf("searchItems: %w", err) + } + var resp jellyfinItemsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("searchItems: %w", err) + } + return resp.Items, nil +} + +func (c *JellyfinClient) primaryImageURL(item jellyfinItem) (string, error) { + if item.ID == "" { + return "", fmt.Errorf("primaryImageURL: missing item id") + } + if len(item.ImageTags) > 0 { + if _, ok := item.ImageTags["Primary"]; !ok { + return "", fmt.Errorf("primaryImageURL: item has no primary image") + } + } + // api_key query param so imagecache can fetch without custom headers + return fmt.Sprintf("%s/Items/%s/Images/Primary?api_key=%s", + c.url, url.PathEscape(item.ID), url.QueryEscape(c.apiKey)), nil +} + +func providerIDEquals(ids map[string]string, keys []string, want string) bool { + if ids == nil || want == "" { + return false + } + want = strings.ToLower(want) + for _, key := range keys { + if v, ok := ids[key]; ok && strings.EqualFold(v, want) { + return true + } + } + return false +} + +func (c *JellyfinClient) pickAlbumImage(items []jellyfinItem, mbid *uuid.UUID, artist string) (string, bool) { + mbidKeys := []string{"MusicBrainzAlbum", "MusicBrainzReleaseGroup"} + if mbid != nil && *mbid != uuid.Nil { + for _, item := range items { + if providerIDEquals(item.ProviderIds, mbidKeys, mbid.String()) { + if img, err := c.primaryImageURL(item); err == nil { + return img, true + } + } + } + } + for _, item := range items { + if artist != "" && !strings.EqualFold(item.AlbumArtist, artist) { + continue + } + if img, err := c.primaryImageURL(item); err == nil { + return img, true + } + } + for _, item := range items { + if img, err := c.primaryImageURL(item); err == nil { + return img, true + } + } + return "", false +} + +func (c *JellyfinClient) GetAlbumImage(ctx context.Context, mbid *uuid.UUID, artist, album string) (string, error) { + l := logger.FromContext(ctx) + l.Debug().Msgf("Finding album image for %s from artist %s via Jellyfin", album, artist) + + if album != "" { + l.Debug().Str("title", album).Str("artist", artist).Msg("Searching Jellyfin album image by title") + items, err := c.searchItems(ctx, "MusicAlbum", album) + if err != nil { + return "", fmt.Errorf("GetAlbumImage: %w", err) + } + if img, ok := c.pickAlbumImage(items, mbid, artist); ok { + return img, nil + } + } + + if mbid != nil && *mbid != uuid.Nil { + l.Debug().Str("mbid", mbid.String()).Msg("Searching Jellyfin album image by MBID") + items, err := c.searchItems(ctx, "MusicAlbum", mbid.String()) + if err != nil { + return "", fmt.Errorf("GetAlbumImage: %w", err) + } + if img, ok := c.pickAlbumImage(items, mbid, artist); ok { + return img, nil + } + } + + return "", fmt.Errorf("GetAlbumImage: failed to get album art from jellyfin") +} + +func (c *JellyfinClient) GetArtistImage(ctx context.Context, mbid *uuid.UUID, artist string) (string, error) { + l := logger.FromContext(ctx) + l.Debug().Msgf("Finding artist image for %s via Jellyfin", artist) + + mbidKeys := []string{"MusicBrainzArtist"} + + if mbid != nil && *mbid != uuid.Nil { + l.Debug().Str("mbid", mbid.String()).Msg("Searching Jellyfin artist image by MBID") + items, err := c.searchItems(ctx, "MusicArtist", mbid.String()) + if err != nil { + return "", fmt.Errorf("GetArtistImage: %w", err) + } + for _, item := range items { + if providerIDEquals(item.ProviderIds, mbidKeys, mbid.String()) { + img, err := c.primaryImageURL(item) + if err != nil { + continue + } + return img, nil + } + } + } + + if artist == "" { + return "", fmt.Errorf("GetArtistImage: failed to get artist art from jellyfin") + } + + l.Debug().Str("artist", artist).Msg("Searching Jellyfin artist image by name") + items, err := c.searchItems(ctx, "MusicArtist", artist) + if err != nil { + return "", fmt.Errorf("GetArtistImage: %w", err) + } + if len(items) < 1 { + return "", fmt.Errorf("GetArtistImage: failed to get artist art from jellyfin") + } + + if mbid != nil && *mbid != uuid.Nil { + for _, item := range items { + if providerIDEquals(item.ProviderIds, mbidKeys, mbid.String()) { + img, err := c.primaryImageURL(item) + if err != nil { + continue + } + return img, nil + } + } + } + + for _, item := range items { + if !strings.EqualFold(item.Name, artist) { + continue + } + img, err := c.primaryImageURL(item) + if err != nil { + continue + } + return img, nil + } + + for _, item := range items { + img, err := c.primaryImageURL(item) + if err != nil { + continue + } + return img, nil + } + + return "", fmt.Errorf("GetArtistImage: failed to get artist art from jellyfin") +}