diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 3a501d6..bf9437a 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -66,6 +66,12 @@ If the environment variable is defined without **and** with the suffix at the sa - Description: Disables Cover Art Archive as a source for finding album images. ##### KOITO_DISABLE_MUSICBRAINZ - Default: `false` +##### KOITO_SUBSONIC_URL +- Required: `true` if KOITO_SUBSONIC_PARAMS is set +- Description: The URL of your subsonic compatible music server. For example, `https://navidrome.mydomain.com`. +##### KOITO_SUBSONIC_PARAMS +- Required: `true` if KOITO_SUBSONIC_URL is set +- Description: The `u`, `t`, and `s` authentication parameters to use for authenticated requests to your subsonic server, in the format `u=XXX&t=XXX&s=XXX`. An easy way to find them is to open the network tab in the developer tools of your browser of choice and copy them from a request. ##### KOITO_SKIP_IMPORT - Default: `false` - Description: Skips running the importer on startup. @@ -84,4 +90,4 @@ If the environment variable is defined without **and** with the suffix at the sa - Description: When true, images will be downloaded and cached during imports. ##### KOITO_CORS_ALLOWED_ORIGINS - Default: No CORS policy -- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins. \ No newline at end of file +- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins. diff --git a/engine/engine.go b/engine/engine.go index ae05b54..b8e01b8 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -107,9 +107,10 @@ func Run( l.Debug().Msg("Engine: Initializing image sources") images.Initialize(images.ImageSourceOpts{ - UserAgent: cfg.UserAgent(), - EnableCAA: !cfg.CoverArtArchiveDisabled(), - EnableDeezer: !cfg.DeezerDisabled(), + UserAgent: cfg.UserAgent(), + EnableCAA: !cfg.CoverArtArchiveDisabled(), + EnableDeezer: !cfg.DeezerDisabled(), + EnableSubsonic: cfg.SubsonicEnabled(), }) l.Info().Msg("Engine: Image sources initialized") diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 8f702ed..b5d945e 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -35,6 +35,8 @@ const ( DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" + SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL" + SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS" SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS" @@ -65,6 +67,9 @@ type config struct { disableDeezer bool disableCAA bool disableMusicBrainz bool + subsonicUrl string + subsonicParams string + subsonicEnabled bool skipImport bool fetchImageDuringImport bool allowedHosts []string @@ -149,6 +154,12 @@ func loadConfig(getenv func(string) string, version string) (*config, error) { cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV)) cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV)) cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV)) + 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 == "") { + 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.skipImport = parseBool(getenv(SKIP_IMPORT_ENV)) cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version) @@ -311,6 +322,24 @@ func MusicBrainzDisabled() bool { return globalConfig.disableMusicBrainz } +func SubsonicEnabled() bool { + lock.RLock() + defer lock.RUnlock() + return globalConfig.subsonicEnabled +} + +func SubsonicUrl() string { + lock.RLock() + defer lock.RUnlock() + return globalConfig.subsonicUrl +} + +func SubsonicParams() string { + lock.RLock() + defer lock.RUnlock() + return globalConfig.subsonicParams +} + func SkipImport() bool { lock.RLock() defer lock.RUnlock() diff --git a/internal/images/imagesrc.go b/internal/images/imagesrc.go index e906c4d..21eec65 100644 --- a/internal/images/imagesrc.go +++ b/internal/images/imagesrc.go @@ -12,14 +12,17 @@ import ( ) type ImageSource struct { - deezerEnabled bool - deezerC *DeezerClient - caaEnabled bool + deezerEnabled bool + deezerC *DeezerClient + subsonicEnabled bool + subsonicC *SubsonicClient + caaEnabled bool } type ImageSourceOpts struct { - UserAgent string - EnableCAA bool - EnableDeezer bool + UserAgent string + EnableCAA bool + EnableDeezer bool + EnableSubsonic bool } var once sync.Once @@ -48,6 +51,10 @@ func Initialize(opts ImageSourceOpts) { imgsrc.deezerEnabled = true imgsrc.deezerC = NewDeezerClient() } + if opts.EnableSubsonic { + imgsrc.subsonicEnabled = true + imgsrc.subsonicC = NewSubsonicClient() + } }) } @@ -57,6 +64,16 @@ func Shutdown() { func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) { l := logger.FromContext(ctx) + if imgsrc.subsonicEnabled { + img, err := imgsrc.subsonicC.GetArtistImage(ctx, opts.Aliases[0]) + if err != nil { + return "", err + } + if img != "" { + return img, nil + } + l.Debug().Msg("Could not find artist image from Subsonic") + } if imgsrc.deezerC != nil { img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases) if err != nil { @@ -69,6 +86,16 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) { } func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { l := logger.FromContext(ctx) + if imgsrc.subsonicEnabled { + img, err := imgsrc.subsonicC.GetAlbumImage(ctx, opts.Artists[0], opts.Album) + if err != nil { + return "", err + } + if img != "" { + return img, nil + } + l.Debug().Msg("Could not find album cover from Subsonic") + } if imgsrc.caaEnabled { l.Debug().Msg("Attempting to find album image from CoverArtArchive") if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil { diff --git a/internal/images/subsonic.go b/internal/images/subsonic.go new file mode 100644 index 0000000..961b4c2 --- /dev/null +++ b/internal/images/subsonic.go @@ -0,0 +1,137 @@ +package images + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/gabehf/koito/internal/cfg" + "github.com/gabehf/koito/internal/logger" + "github.com/gabehf/koito/queue" +) + +type SubsonicClient struct { + url string + userAgent string + authParams string + requestQueue *queue.RequestQueue +} + +type SubsonicAlbumResponse struct { + SubsonicResponse struct { + Status string `json:"status"` + SearchResult3 struct { + Album []struct { + CoverArt string `json:"coverArt"` + } `json:"album"` + } `json:"searchResult3"` + } `json:"subsonic-response"` +} + +type SubsonicArtistResponse struct { + SubsonicResponse struct { + Status string `json:"status"` + SearchResult3 struct { + Artist []struct { + ArtistImageUrl string `json:"artistImageUrl"` + } `json:"artist"` + } `json:"searchResult3"` + } `json:"subsonic-response"` +} + +const ( + subsonicAlbumSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=0&songCount=0&albumCount=1" + subsonicArtistSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=1&songCount=0&albumCount=0" + subsonicCoverArtFmtStr = "/rest/getCoverArt?%s&id=%s&v=1.13.0&c=koito" +) + +func NewSubsonicClient() *SubsonicClient { + ret := new(SubsonicClient) + ret.url = cfg.SubsonicUrl() + ret.userAgent = cfg.UserAgent() + ret.authParams = cfg.SubsonicParams() + ret.requestQueue = queue.NewRequestQueue(5, 5) + return ret +} + +func (c *SubsonicClient) 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") + + 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.RequestURI).Msg("Failed to contact ImageSrc") + done <- queue.RequestResult{Err: err} + return + } else if resp.StatusCode >= 300 || resp.StatusCode < 200 { + err = fmt.Errorf("recieved non-ok status from Subsonic: %s", resp.Status) + done <- queue.RequestResult{Body: nil, Err: err} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + done <- queue.RequestResult{Body: body, Err: err} + }) + + result := <-resultChan + return result.Body, result.Err +} + +func (c *SubsonicClient) getEntity(ctx context.Context, endpoint string, result any) error { + l := logger.FromContext(ctx) + url := c.url + endpoint + l.Debug().Msgf("Sending request to ImageSrc: GET %s", url) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("getEntity: %w", err) + } + l.Debug().Msg("Adding ImageSrc request to queue") + body, err := c.queue(ctx, req) + if err != nil { + l.Err(err).Msg("Subsonic request failed") + return fmt.Errorf("getEntity: %w", err) + } + + err = json.Unmarshal(body, result) + if err != nil { + l.Err(err).Msg("Failed to unmarshal Subsonic response") + return fmt.Errorf("getEntity: %w", err) + } + + return nil +} + +func (c *SubsonicClient) GetAlbumImage(ctx context.Context, artist, album string) (string, error) { + l := logger.FromContext(ctx) + resp := new(SubsonicAlbumResponse) + l.Debug().Msgf("Finding album image for %s from artist %s", album, artist) + err := c.getEntity(ctx, fmt.Sprintf(subsonicAlbumSearchFmtStr, c.authParams, url.QueryEscape(artist+" "+album)), resp) + if err != nil { + return "", fmt.Errorf("GetAlbumImage: %v", err) + } + l.Debug().Any("subsonic_response", resp).Send() + if len(resp.SubsonicResponse.SearchResult3.Album) < 1 || resp.SubsonicResponse.SearchResult3.Album[0].CoverArt == "" { + return "", fmt.Errorf("GetAlbumImage: failed to get album art") + } + return cfg.SubsonicUrl() + fmt.Sprintf(subsonicCoverArtFmtStr, c.authParams, url.QueryEscape(resp.SubsonicResponse.SearchResult3.Album[0].CoverArt)), nil +} + +func (c *SubsonicClient) GetArtistImage(ctx context.Context, artist string) (string, error) { + l := logger.FromContext(ctx) + resp := new(SubsonicArtistResponse) + l.Debug().Msgf("Finding artist image for %s", artist) + err := c.getEntity(ctx, fmt.Sprintf(subsonicArtistSearchFmtStr, c.authParams, url.QueryEscape(artist)), resp) + if err != nil { + return "", fmt.Errorf("GetArtistImage: %v", err) + } + l.Debug().Any("subsonic_response", resp).Send() + if len(resp.SubsonicResponse.SearchResult3.Artist) < 1 || resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl == "" { + return "", fmt.Errorf("GetArtistImage: failed to get artist art") + } + return resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl, nil +}