Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package main

import (
"fmt"
"log"
"os"
"strings"
"log"

"github.com/gabehf/koito/engine"
)
Expand Down
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default defineConfig({
label: "Quickstart",
items: [
{ label: "Setup with Navidrome", slug: "quickstart/navidrome" },
{ label: "Setup with Jellyfin", slug: "quickstart/jellyfin" },
],
},
{
Expand Down
39 changes: 39 additions & 0 deletions docs/src/content/docs/quickstart/jellyfin.md
Original file line number Diff line number Diff line change
@@ -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=<jellyfin_api_key>
# Optional if Items searches return nothing with the API key alone:
# - KOITO_JELLYFIN_USER_ID=<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.
19 changes: 19 additions & 0 deletions docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
23 changes: 23 additions & 0 deletions engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() != "",
})
Expand Down
18 changes: 16 additions & 2 deletions internal/cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down
24 changes: 24 additions & 0 deletions internal/cfg/getters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 0 additions & 1 deletion internal/db/sqlite/exports.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,3 @@ func (s *Sqlite) GetExportPage(ctx context.Context, opts db.GetExportPageOpts) (
}
return items, nil
}

46 changes: 36 additions & 10 deletions internal/images/imagesrc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand All @@ -68,24 +76,32 @@ 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 {
l.Debug().Err(err).Msg("GetArtistImage: Could not find artist image from Subsonic")
} 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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading