Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support embedded album art covers #556

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions cmd/gonic/gonic.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
},
tagReader,
*confExcludePattern,
cacheDirCovers,
)
podcast := podcast.New(dbc, *confPodcastPath, tagReader)
transcoder := transcode.NewCachingTranscoder(
Expand Down Expand Up @@ -535,10 +536,10 @@
if delim == "" {
return fmt.Errorf("no delimiter provided for delimiter mode")
}
mvs.Mode = scanner.Delim

Check failure on line 539 in cmd/gonic/gonic.go

View workflow job for this annotation

GitHub Actions / Lint and test

mvs.Mode undefined (type *multiValueSetting has no field or method Mode) (typecheck)
mvs.Delim = delim

Check failure on line 540 in cmd/gonic/gonic.go

View workflow job for this annotation

GitHub Actions / Lint and test

mvs.Delim undefined (type *multiValueSetting has no field or method Delim) (typecheck)
case "multi":
mvs.Mode = scanner.Multi

Check failure on line 542 in cmd/gonic/gonic.go

View workflow job for this annotation

GitHub Actions / Lint and test

mvs.Mode undefined (type *multiValueSetting has no field or method Mode) (typecheck)
case "none":
default:
return fmt.Errorf(`unknown multi value mode %q. should be "none" | "multi" | "delim <delim>"`, mode)
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module go.senan.xyz/gonic

go 1.23.0

replace github.com/sentriz/audiotags => github.com/turtletowerz/audiotags v1.0.1-0.20250112090906-d044f29536ef

require (
github.com/Masterminds/sprig v2.22.0+incompatible
github.com/andybalholm/cascadia v1.3.2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sentriz/audiotags v0.0.0-20240918190302-048d6470aae6 h1:WCZxu77OR9yzKGZugQC1dHhslXROOJT+UL5JCtJbBq8=
github.com/sentriz/audiotags v0.0.0-20240918190302-048d6470aae6/go.mod h1:+pmkMFDEXJuu/u4h2OYJVfYF2qIhXJD7kqvWq6q5Zo0=
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 h1:sLILANWN76ja66/K4k/mBqJuCjDZaM67w+Ru6rEB0s0=
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981/go.mod h1:Rx8XB1ck+so+41uu9VY1gMKs1CPQ2NTq0pzf+OCCQHo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
Expand All @@ -138,6 +136,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/turtletowerz/audiotags v1.0.1-0.20250112090906-d044f29536ef h1:uK0EcJGS9pHWzYDNTGd+R9aMsv96XSiNqHM1rO9RHKc=
github.com/turtletowerz/audiotags v1.0.1-0.20250112090906-d044f29536ef/go.mod h1:+pmkMFDEXJuu/u4h2OYJVfYF2qIhXJD7kqvWq6q5Zo0=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.senan.xyz/flagconf v0.1.9 h1:LBDmqiVFgijfqFXDzH97gPn0qDbg1Dq6/vxsxS/TzC4=
go.senan.xyz/flagconf v0.1.9/go.mod h1:NqOFfSwJvNWXOTUabcRZ8mPK9+sJmhStJhqtEt74wNQ=
Expand Down
2 changes: 1 addition & 1 deletion mockfs/mockfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func newMockFS(tb testing.TB, dirs []string, excludePattern string) *MockFS {
}

tagReader := &tagReader{paths: map[string]*TagInfo{}}
scanner := scanner.New(absDirs, dbc, multiValueSettings, tagReader, excludePattern)
scanner := scanner.New(absDirs, dbc, multiValueSettings, tagReader, excludePattern, "")

return &MockFS{
t: tb,
Expand Down
19 changes: 18 additions & 1 deletion scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ type Scanner struct {
tagReader tagcommon.Reader
excludePattern *regexp.Regexp
scanning *int32
cacheCoverPath string
}

func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSetting, tagReader tagcommon.Reader, excludePattern string) *Scanner {
func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSetting, tagReader tagcommon.Reader, excludePattern, cacheCoverPath string) *Scanner {
var excludePatternRegExp *regexp.Regexp
if excludePattern != "" {
excludePatternRegExp = regexp.MustCompile(excludePattern)
Expand All @@ -55,6 +56,7 @@ func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSet
tagReader: tagReader,
excludePattern: excludePatternRegExp,
scanning: new(int32),
cacheCoverPath: cacheCoverPath,
}
}

Expand Down Expand Up @@ -310,6 +312,21 @@ func (s *Scanner) scanDir(tx *db.DB, st *State, absPath string) error {
if err := s.populateTrackAndArtists(tx, st, i, &album, basename, absPath); err != nil {
return fmt.Errorf("populate track %q: %w", basename, err)
}

// This is done after track populating in case of any unexpected errors
// Grabbing the first cover available is not ideal but it's the best solution at the moment
if cover == "" {
img := tagcommon.EmbeddedCover(absPath)
if img != nil {
cachePath := tagcommon.CachePath(s.cacheCoverPath, album.SID().String(), tagcommon.CoverDefaultSize)
if err = tagcommon.CoverScaleAndSave(img, cachePath, tagcommon.CoverDefaultSize); err != nil {
return fmt.Errorf("caching embedded art: %w", err)
}

// This is a lazy way to do this, but is the easiest without moving too much around
cover = "embedded"
}
}
}

return nil
Expand Down
47 changes: 18 additions & 29 deletions server/ctrlsubsonic/handlers_raw.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"path/filepath"
"time"

"github.com/disintegration/imaging"
"github.com/jinzhu/gorm"

"go.senan.xyz/gonic/db"
Expand All @@ -21,6 +20,7 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/ctrlsubsonic/specidpaths"
"go.senan.xyz/gonic/tags/tagcommon"
"go.senan.xyz/gonic/transcode"
)

Expand All @@ -30,32 +30,37 @@ import (
// b) return a non-nil spec.Response
// _but not both_

const (
coverDefaultSize = 600
coverCacheFormat = "png"
)

func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *spec.Response {
params := r.Context().Value(CtxParams).(params.Params)
id, err := params.GetID("id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
size := params.GetOrInt("size", coverDefaultSize)
cachePath := filepath.Join(
c.cacheCoverPath,
fmt.Sprintf("%s-%d.%s", id.String(), size, coverCacheFormat),
)
size := params.GetOrInt("size", tagcommon.CoverDefaultSize)
cachePath := tagcommon.CachePath(c.cacheCoverPath, id.String(), size)
_, err = os.Stat(cachePath)
switch {
case os.IsNotExist(err):
reader, err := coverFor(c.dbc, c.artistInfoCache, id)
if err != nil && errors.Is(err, errCoverEmpty) {
// If the DB cover is empty, there could be a situation where an embedded cover needs to be downscaled.
// e.g. embedded-600.png exists, but we need embedded-300.png and it doesn't exist in the cache folder.
cachePathDefault := tagcommon.CachePath(c.cacheCoverPath, id.String(), tagcommon.CoverDefaultSize)
if _, err = os.Stat(cachePathDefault); err != nil {
// We want to silently fail here because it means an embedded cover doesn't exist, which is okay.
return nil
}

reader, err = os.Open(cachePathDefault)
}

if err != nil {
return spec.NewError(10, "couldn't find cover %q: %v", id, err)
log.Printf("couldn't find cover %q: %v", id, err)
return nil
}
defer reader.Close()

if err := coverScaleAndSave(reader, cachePath, size); err != nil {
if err := tagcommon.CoverScaleAndSave(reader, cachePath, size); err != nil {
log.Printf("error scaling cover: %v", err)
return nil
}
Expand Down Expand Up @@ -150,22 +155,6 @@ func coverGetPathPodcastEpisode(dbc *db.DB, id int) (*os.File, error) {
return os.Open(filepath.Join(pe.Podcast.RootDir, pe.Podcast.Image))
}

func coverScaleAndSave(reader io.Reader, cachePath string, size int) error {
src, err := imaging.Decode(reader)
if err != nil {
return fmt.Errorf("resizing: %w", err)
}
width := size
if width > src.Bounds().Dx() {
// don't upscale images
width = src.Bounds().Dx()
}
if err := imaging.Save(imaging.Resize(src, width, 0, imaging.Lanczos), cachePath); err != nil {
return fmt.Errorf("caching %q: %w", cachePath, err)
}
return nil
}

func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.Response {
params := r.Context().Value(CtxParams).(params.Params)
user := r.Context().Value(CtxUser).(*db.User)
Expand Down
12 changes: 3 additions & 9 deletions server/ctrlsubsonic/spec/construct_by_folder.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,14 @@ func NewAlbumByFolder(f *db.Album) *Album {
Duration: f.Duration,
Created: f.CreatedAt,
AverageRating: formatRating(f.AverageRating),
CoverID: f.SID(),
}
if f.AlbumStar != nil {
a.Starred = &f.AlbumStar.StarDate
}
if f.AlbumRating != nil {
a.UserRating = f.AlbumRating.Rating
}
if f.Cover != "" {
a.CoverID = f.SID()
}
return a
}

Expand All @@ -40,16 +38,14 @@ func NewTCAlbumByFolder(f *db.Album) *TrackChild {
ParentID: f.ParentSID(),
CreatedAt: f.CreatedAt,
AverageRating: formatRating(f.AverageRating),
CoverID: f.SID(),
}
if f.AlbumStar != nil {
trCh.Starred = &f.AlbumStar.StarDate
}
if f.AlbumRating != nil {
trCh.UserRating = f.AlbumRating.Rating
}
if f.Cover != "" {
trCh.CoverID = f.SID()
}
return trCh
}

Expand Down Expand Up @@ -77,13 +73,11 @@ func NewTCTrackByFolder(t *db.Track, parent *db.Album) *TrackChild {
MusicBrainzID: t.TagBrainzID,
CreatedAt: t.CreatedAt,
AverageRating: formatRating(t.AverageRating),
CoverID: parent.SID(),
}
if trCh.Title == "" {
trCh.Title = t.Filename
}
if parent.Cover != "" {
trCh.CoverID = parent.SID()
}
if t.Album != nil {
trCh.Album = t.Album.RightPath
}
Expand Down
8 changes: 2 additions & 6 deletions server/ctrlsubsonic/spec/construct_by_tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ func NewAlbumByTags(a *db.Album, artists []*db.Artist) *Album {
Year: a.TagYear,
Tracks: []*TrackChild{},
AverageRating: formatRating(a.AverageRating),
}
if a.Cover != "" {
ret.CoverID = a.SID()
CoverID: a.SID(),
}
if a.AlbumStar != nil {
ret.Starred = &a.AlbumStar.StarDate
Expand Down Expand Up @@ -84,9 +82,7 @@ func NewTrackByTags(t *db.Track, album *db.Album) *TrackChild {
Year: album.TagYear,
AverageRating: formatRating(t.AverageRating),
TranscodeMeta: TranscodeMeta{},
}
if album.Cover != "" {
ret.CoverID = album.SID()
CoverID: album.SID(),
}
if t.TrackStar != nil {
ret.Starred = &t.TrackStar.StarDate
Expand Down
43 changes: 43 additions & 0 deletions tags/tagcommon/tagcommmon.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

import (
"errors"
"fmt"
"io"
"path/filepath"

"github.com/disintegration/imaging"
"github.com/sentriz/audiotags"

Check failure on line 10 in tags/tagcommon/tagcommmon.go

View workflow job for this annotation

GitHub Actions / Lint and test

could not import github.com/sentriz/audiotags (-: # github.com/sentriz/audiotags
)

var ErrUnsupported = errors.New("filetype unsupported")
Expand Down Expand Up @@ -39,8 +45,45 @@
FallbackAlbum = "Unknown Album"
FallbackArtist = "Unknown Artist"
FallbackGenre = "Unknown Genre"

CoverDefaultSize = 600
)

func CachePath(cacheDir, id string, size int) string {
return filepath.Join(cacheDir, fmt.Sprintf("%s-%d.png", id, size))
}

func CoverScaleAndSave(reader io.Reader, cachePath string, size int) error {
src, err := imaging.Decode(reader)
if err != nil {
return fmt.Errorf("resizing: %w", err)
}
width := size
if width > src.Bounds().Dx() {
// don't upscale images
width = src.Bounds().Dx()
}
if err := imaging.Save(imaging.Resize(src, width, 0, imaging.Lanczos), cachePath); err != nil {
return fmt.Errorf("caching %q: %w", cachePath, err)
}
return nil
}

// TODO: Find a better place to put this
func EmbeddedCover(absPath string) io.Reader {
f, err := audiotags.Open(absPath)
if err != nil {
return nil
}
defer f.Close()

raw := f.ReadImageRaw()
if raw == nil || raw.Len() == 0 {
return nil
}
return raw
}

func MustAlbum(p Info) string {
if r := p.Album(); r != "" {
return r
Expand Down
Loading