Skip to content

Commit

Permalink
Add support for serving artwork thumbnails
Browse files Browse the repository at this point in the history
This will hopefully greatly reduce the data downloaded for big lists of
albums or artists which include images.
  • Loading branch information
ironsmile committed Apr 26, 2021
1 parent f0f6d87 commit 23323e0
Show file tree
Hide file tree
Showing 55 changed files with 16,306 additions and 91 deletions.
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,10 @@ When started for the first time HTTPMS will create one for you. Here is an examp
"sleep_after_operation": "15ms"
},

// When true, HTTPMS will search Cover Art Archive for album artworks when none is
// found locally. Anything found will be saved in the HTTPMS database and later used
// instead of further calls to the archive.
// When true, HTTPMS will search for images on the internet. This means album artwork
// and artists images. Cover Art Archive is used for album artworks when none is
// found locally. And Discogs for artist images. Anything found will be saved in
// the HTTPMS database and later used to prevent further calls to the archive.
"download_artwork": true,

// If download_artwork is true the server will try to find artist artwork in the
Expand Down Expand Up @@ -365,7 +366,9 @@ HTTPMS supports album artwork. Here are all the methods for managing it through
GET /v1/album/{albumID}/artwork
```

Returns a bitmap image with artwork for this album if one is available. Searching for artwork works like this: the album's directory would be scanned for any images (png/jpeg/gif/tiff files) and if anyone of them looks like an artwork, it would be shown. If this fails, you can configure HTTPMS to search in the [MusicBrainz Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive/). By default no external calls are made.
Returns a bitmap image with artwork for this album if one is available. Searching for artwork works like this: the album's directory would be scanned for any images (png/jpeg/gif/tiff files) and if anyone of them looks like an artwork, it would be shown. If this fails, you can configure HTTPMS to search in the [MusicBrainz Cover Art Archive](https://musicbrainz.org/doc/Cover_Art_Archive/). By default no external calls are made, see the 'download_artwork' configuration property.

By default the full size image will be served. One could request a thumbnail by appending the `?size=small` query.

#### Upload Artwork

Expand Down Expand Up @@ -401,6 +404,8 @@ GET /v1/artist/{artistID}/image

Returns a bitmap image representing an artist if one is available. Searching for artwork works like this: if artist image is found in the database then it will be used. In case there is not and HTTPMS is configured to download images from interned and has a Discogs access token then it will use the MusicBrainz and Discogs APIs in order to retrieve an image. By default no internet requests are made.

By default the full size image will be served. One could request a thumbnail by appending the `?size=small` query.

#### Upload Artist Image

```
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ require (
github.com/pkg/errors v0.8.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20171229120447-cf5f9fa2f0d8
github.com/wtolson/go-taglib v0.0.0-20180718000046-586eb63c2628
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
gopkg.in/gorp.v1 v1.7.1 // indirect
gopkg.in/mineo/gocaa.v1 v1.0.0-20180225115936-2500f801cd83
)
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ github.com/skip2/go-qrcode v0.0.0-20171229120447-cf5f9fa2f0d8 h1:5C4yAeYifeRO+7z
github.com/skip2/go-qrcode v0.0.0-20171229120447-cf5f9fa2f0d8/go.mod h1:PLPIyL7ikehBD1OAjmKKiOEhbvWyHGaNDjquXMcYABo=
github.com/wtolson/go-taglib v0.0.0-20180718000046-586eb63c2628 h1:hYOyf8es7yvMucqlPar3CdobJeY0+0OmR5iT4hHYW54=
github.com/wtolson/go-taglib v0.0.0-20180718000046-586eb63c2628/go.mod h1:p+WHGfN/a+Ol37Pm7EIOO/6Cylieb2qn1jmKfxtSsUg=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/gorp.v1 v1.7.1 h1:GBB9KrWRATQZh95HJyVGUZrWwOPswitEYEyqlK8JbAA=
gopkg.in/gorp.v1 v1.7.1/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
gopkg.in/mineo/gocaa.v1 v1.0.0-20180225115936-2500f801cd83 h1:ftgXME9bLz+f6fqgev+fjN7WFPrBPa0Ug3dYBW1OSBw=
Expand Down
2 changes: 1 addition & 1 deletion sqls/migrations/003_track_duration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
alter table tracks add column duration integer;

-- +migrate Down
alter table drop column duration;
alter table tracks drop column duration;
7 changes: 7 additions & 0 deletions sqls/migrations/005_small_images.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- +migrate Up
alter table `artists_images` add column `image_small` blob default null;
alter table `albums_artworks` add column `artwork_cover_small` blob default null;

-- +migrate Down
alter table `artists_images` drop column `image_small`;
alter table `albums_artworks` drop column `artwork_cover_small`;
176 changes: 137 additions & 39 deletions src/library/artist_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,70 @@ import (
func (lib *LocalLibrary) FindAndSaveArtistImage(
ctx context.Context,
artistID int64,
size ImageSize,
) (io.ReadCloser, error) {
reader, err := lib.artistImageFromDB(ctx, artistID)
r, foundSize, err := lib.findAndSaveArtistImageOrOriginal(
ctx,
artistID,
size,
)
if err != nil {
return r, err
}

if foundSize == size {
return r, nil
}

// The returned artwork will have to be closed in any case now since it
// will not be returned to the caller.
defer func() {
_ = r.Close()
}()

if foundSize != OriginalImage {
return nil, fmt.Errorf("internal error, size mismatch")
}

converted, err := lib.scaleImage(ctx, r, size)
if err != nil {
return nil, fmt.Errorf("error scaling image: %w", err)
}

ret, _, err := lib.storeArtistImage(artistID, converted, size)
if err != nil {
return nil, err
}

return ret, nil
}

func (lib *LocalLibrary) findAndSaveArtistImageOrOriginal(
ctx context.Context,
artistID int64,
size ImageSize,
) (io.ReadCloser, ImageSize, error) {
reader, foundSize, err := lib.artistImageFromDB(ctx, artistID, size)
if err == ErrCachedArtworkNotFound {
return nil, ErrArtworkNotFound
return nil, size, ErrArtworkNotFound
} else if err == nil || err != ErrArtworkNotFound {
return reader, err
return reader, foundSize, err
}

if err := lib.aquireArtworkSem(ctx); err != nil {
// When error is returned it means that the semaphore was not acquired.
// So we can return safely without releasing it.
return nil, err
return nil, size, err
}
defer lib.releaseArtworkSem()

if err := ctx.Err(); err != nil {
return nil, err
return nil, size, err
}

reader, err = lib.artistImageFromInternet(ctx, artistID)
if err == nil {
return lib.saveArtistImage(artistID, reader)
return lib.storeArtistImage(artistID, reader, OriginalImage)
}

if errors.Is(err, art.ErrNoDiscogsAuth) {
Expand All @@ -57,34 +99,85 @@ func (lib *LocalLibrary) FindAndSaveArtistImage(
}

if err := lib.saveArtistImageNotFound(artistID); err != nil {
return nil, err
return nil, size, err
}

return nil, ErrArtworkNotFound
return nil, size, ErrArtworkNotFound
}

func (lib *LocalLibrary) artistImageFromDB(
ctx context.Context,
artistID int64,
) (io.ReadCloser, error) {
var (
buff []byte
unixTime int64
)
size ImageSize,
) (io.ReadCloser, ImageSize, error) {
buff, unixTime, err := lib.artistImageFromDBForSize(ctx, artistID, size)
if err != nil {
return nil, size, err
}

work := func(db *sql.DB) error {
smt, err := db.PrepareContext(ctx, `
if len(buff) >= 1 {
// The image with the desires size has been found!
return newBytesReadCloser(buff), size, nil
}

selectNotFound := func(lastChanged int64) (io.ReadCloser, ImageSize, error) {
if time.Now().Before(time.Unix(lastChanged, 0).Add(notFoundCacheTTL)) {
return nil, size, ErrCachedArtworkNotFound
}
return nil, size, ErrArtworkNotFound
}

// No image in the database. Is either a normal "not found" for images which
// haven't been queried recently. For everything else it is "cached not found"
// which means that all the channels for obtaining the image have been tried out
// recently and nothing has been found.
if size == OriginalImage {
return selectNotFound(unixTime)
}

// No image of the desired size was found. Let us try and see if the original image
// is in the database and use it to generate the desired size.
buff, unixTime, err = lib.artistImageFromDBForSize(ctx, artistID, OriginalImage)
if err != nil {
return nil, size, err
}

if len(buff) < 1 {
return selectNotFound(unixTime)
}

return newBytesReadCloser(buff), OriginalImage, nil
}

func (lib *LocalLibrary) artistImageFromDBForSize(
ctx context.Context,
artistID int64,
size ImageSize,
) ([]byte, int64, error) {

var (
buff []byte
unixTime int64
blobColumn = "image"
imageSQLQuery = `
SELECT
image,
%s,
updated_at
FROM
artists_images
WHERE
artist_id = ?
`)
`
)
if size == SmallImage {
blobColumn = "image_small"
}

work := func(db *sql.DB) error {
smt, err := db.PrepareContext(ctx, fmt.Sprintf(imageSQLQuery, blobColumn))

if err != nil {
log.Printf("could not prepare artist image sql statement: %s", err)
log.Printf("could not prepare album artwork sql statement: %s", err)
return err
}
defer smt.Close()
Expand All @@ -93,24 +186,17 @@ func (lib *LocalLibrary) artistImageFromDB(
if err == sql.ErrNoRows {
return ErrArtworkNotFound
} else if err != nil {
log.Printf("error getting artist image from db: %s", err)
log.Printf("error getting album cover from db: %s", err)
return err
}

return nil
}
if err := lib.executeDBJobAndWait(work); err != nil {
return nil, err
return nil, 0, err
}

if len(buff) < 1 {
if time.Now().Before(time.Unix(unixTime, 0).Add(24 * 7 * time.Hour)) {
return nil, ErrCachedArtworkNotFound
}
return nil, ErrArtistNotFound
}

return newBytesReadCloser(buff), nil
return buff, unixTime, nil
}

func (lib *LocalLibrary) artistImageFromInternet(
Expand Down Expand Up @@ -166,24 +252,36 @@ func (lib *LocalLibrary) artistImageFromInternet(
return newBytesReadCloser(cover), nil
}

func (lib *LocalLibrary) saveArtistImage(
func (lib *LocalLibrary) storeArtistImage(
albumID int64,
image io.ReadCloser,
) (io.ReadCloser, error) {
size ImageSize,
) (io.ReadCloser, ImageSize, error) {
defer image.Close()

buff, err := ioutil.ReadAll(image)
if err != nil {
return nil, err
return nil, size, err
}

imageColumn := "image"
if size == SmallImage {
imageColumn = "image_small"
}

storeQuery := fmt.Sprintf(`
INSERT INTO
artists_images (artist_id, %s, updated_at)
VALUES
($1, $2, $3)
ON CONFLICT (artist_id) DO
UPDATE SET
%s = $2,
updated_at = $3
`, imageColumn, imageColumn)

work := func(db *sql.DB) error {
stmt, err := db.Prepare(`
INSERT OR REPLACE INTO
artists_images (artist_id, image, updated_at)
VALUES
(?, ?, ?)
`)
stmt, err := db.Prepare(storeQuery)

if err != nil {
return err
Expand All @@ -201,10 +299,10 @@ func (lib *LocalLibrary) saveArtistImage(
}
if err := lib.executeDBJobAndWait(work); err != nil {
log.Printf("Error executing save artist image query: %s", err)
return nil, err
return nil, size, err
}

return newBytesReadCloser(buff), nil
return newBytesReadCloser(buff), size, nil
}

func (lib *LocalLibrary) saveArtistImageNotFound(artistID int64) error {
Expand Down

0 comments on commit 23323e0

Please sign in to comment.