mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-15 10:25:55 -07:00
feat: fetch images from subsonic server (#94)
This commit is contained in:
parent
d04ff23911
commit
d0c4d078d5
5 changed files with 210 additions and 10 deletions
|
|
@ -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.
|
- Description: Disables Cover Art Archive as a source for finding album images.
|
||||||
##### KOITO_DISABLE_MUSICBRAINZ
|
##### KOITO_DISABLE_MUSICBRAINZ
|
||||||
- Default: `false`
|
- 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
|
##### KOITO_SKIP_IMPORT
|
||||||
- Default: `false`
|
- Default: `false`
|
||||||
- Description: Skips running the importer on startup.
|
- 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.
|
- Description: When true, images will be downloaded and cached during imports.
|
||||||
##### KOITO_CORS_ALLOWED_ORIGINS
|
##### KOITO_CORS_ALLOWED_ORIGINS
|
||||||
- Default: No CORS policy
|
- 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.
|
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,10 @@ func Run(
|
||||||
|
|
||||||
l.Debug().Msg("Engine: Initializing image sources")
|
l.Debug().Msg("Engine: Initializing image sources")
|
||||||
images.Initialize(images.ImageSourceOpts{
|
images.Initialize(images.ImageSourceOpts{
|
||||||
UserAgent: cfg.UserAgent(),
|
UserAgent: cfg.UserAgent(),
|
||||||
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
||||||
EnableDeezer: !cfg.DeezerDisabled(),
|
EnableDeezer: !cfg.DeezerDisabled(),
|
||||||
|
EnableSubsonic: cfg.SubsonicEnabled(),
|
||||||
})
|
})
|
||||||
l.Info().Msg("Engine: Image sources initialized")
|
l.Info().Msg("Engine: Image sources initialized")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ const (
|
||||||
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
|
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
|
||||||
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
|
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
|
||||||
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
|
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
|
||||||
|
SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL"
|
||||||
|
SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS"
|
||||||
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
|
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
|
||||||
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
|
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
|
||||||
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
|
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
|
||||||
|
|
@ -65,6 +67,9 @@ type config struct {
|
||||||
disableDeezer bool
|
disableDeezer bool
|
||||||
disableCAA bool
|
disableCAA bool
|
||||||
disableMusicBrainz bool
|
disableMusicBrainz bool
|
||||||
|
subsonicUrl string
|
||||||
|
subsonicParams string
|
||||||
|
subsonicEnabled bool
|
||||||
skipImport bool
|
skipImport bool
|
||||||
fetchImageDuringImport bool
|
fetchImageDuringImport bool
|
||||||
allowedHosts []string
|
allowedHosts []string
|
||||||
|
|
@ -149,6 +154,12 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
|
||||||
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
|
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
|
||||||
cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV))
|
cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV))
|
||||||
cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_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.skipImport = parseBool(getenv(SKIP_IMPORT_ENV))
|
||||||
|
|
||||||
cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
|
cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
|
||||||
|
|
@ -311,6 +322,24 @@ func MusicBrainzDisabled() bool {
|
||||||
return globalConfig.disableMusicBrainz
|
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 {
|
func SkipImport() bool {
|
||||||
lock.RLock()
|
lock.RLock()
|
||||||
defer lock.RUnlock()
|
defer lock.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ImageSource struct {
|
type ImageSource struct {
|
||||||
deezerEnabled bool
|
deezerEnabled bool
|
||||||
deezerC *DeezerClient
|
deezerC *DeezerClient
|
||||||
caaEnabled bool
|
subsonicEnabled bool
|
||||||
|
subsonicC *SubsonicClient
|
||||||
|
caaEnabled bool
|
||||||
}
|
}
|
||||||
type ImageSourceOpts struct {
|
type ImageSourceOpts struct {
|
||||||
UserAgent string
|
UserAgent string
|
||||||
EnableCAA bool
|
EnableCAA bool
|
||||||
EnableDeezer bool
|
EnableDeezer bool
|
||||||
|
EnableSubsonic bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
|
|
@ -48,6 +51,10 @@ func Initialize(opts ImageSourceOpts) {
|
||||||
imgsrc.deezerEnabled = true
|
imgsrc.deezerEnabled = true
|
||||||
imgsrc.deezerC = NewDeezerClient()
|
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) {
|
func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
|
||||||
l := logger.FromContext(ctx)
|
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 {
|
if imgsrc.deezerC != nil {
|
||||||
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
|
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
|
||||||
if err != nil {
|
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) {
|
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
|
||||||
l := logger.FromContext(ctx)
|
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 {
|
if imgsrc.caaEnabled {
|
||||||
l.Debug().Msg("Attempting to find album image from CoverArtArchive")
|
l.Debug().Msg("Attempting to find album image from CoverArtArchive")
|
||||||
if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil {
|
if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil {
|
||||||
|
|
|
||||||
137
internal/images/subsonic.go
Normal file
137
internal/images/subsonic.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue