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 3 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
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Install dependencies
run: |
sudo apt update -qq
sudo apt install -y -qq build-essential git sqlite libtag1-dev ffmpeg mpv zlib1g-dev
sudo apt install -y -qq build-essential git sqlite3 libtag1-dev ffmpeg mpv zlib1g-dev
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
Expand Down
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 @@ func main() {
},
tagReader,
*confExcludePattern,
cacheDirCovers,
)
podcast := podcast.New(dbc, *confPodcastPath, tagReader)
transcoder := transcode.NewCachingTranscoder(
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
8 changes: 5 additions & 3 deletions mockfs/mockfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -68,7 +69,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 Expand Up @@ -368,8 +369,9 @@ func (i *TagInfo) ReplayGainTrackPeak() float32 { return 0 }
func (i *TagInfo) ReplayGainAlbumGain() float32 { return 0 }
func (i *TagInfo) ReplayGainAlbumPeak() float32 { return 0 }

func (i *TagInfo) Length() int { return firstInt(100, i.RawLength) }
func (i *TagInfo) Bitrate() int { return firstInt(100, i.RawBitrate) }
func (i *TagInfo) Length() int { return firstInt(100, i.RawLength) }
func (i *TagInfo) Bitrate() int { return firstInt(100, i.RawBitrate) }
func (i *TagInfo) EmbeddedCover(path string) io.Reader { return nil }

var _ tagcommon.Reader = (*tagReader)(nil)

Expand Down
23 changes: 20 additions & 3 deletions 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 @@ -307,15 +309,15 @@ func (s *Scanner) scanDir(tx *db.DB, st *State, absPath string) error {
sort.Strings(tracks)
for i, basename := range tracks {
absPath := filepath.Join(musicDir, relPath, basename)
if err := s.populateTrackAndArtists(tx, st, i, &album, basename, absPath); err != nil {
if err := s.populateTrackAndArtists(tx, st, i, &album, basename, absPath, &cover); err != nil {
return fmt.Errorf("populate track %q: %w", basename, err)
}
}

return nil
}

func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db.Album, basename string, absPath string) error {
func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db.Album, basename string, absPath string, cover *string) error {
// useful to get the real create/birth time for filesystems and kernels which support it
timeSpec, err := times.Stat(absPath)
if err != nil {
Expand Down Expand Up @@ -410,6 +412,21 @@ func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db
st.seenTracks[track.ID] = struct{}{}
st.seenTracksNew++

// 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 := trags.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
28 changes: 28 additions & 0 deletions tags/tagcommon/tagcommmon.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package tagcommon

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

"github.com/disintegration/imaging"
)

var ErrUnsupported = errors.New("filetype unsupported")
Expand All @@ -25,6 +30,7 @@ type Info interface {
TrackNumber() int
DiscNumber() int
Year() int
EmbeddedCover(string) io.Reader

ReplayGainTrackGain() float32
ReplayGainTrackPeak() float32
Expand All @@ -39,8 +45,30 @@ const (
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
}

func MustAlbum(p Info) string {
if r := p.Album(); r != "" {
return r
Expand Down
15 changes: 15 additions & 0 deletions tags/taglib/taglib.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package taglib

import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -60,6 +61,20 @@ func (i *info) ReplayGainAlbumPeak() float32 { return flt(first(find(i.raw, "rep
func (i *info) Length() int { return i.props.Length }
func (i *info) Bitrate() int { return i.props.Bitrate }

func (i *info) EmbeddedCover(path string) io.Reader {
f, err := audiotags.Open(path)
if err != nil {
return nil
}
defer f.Close()

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

func first[T comparable](is []T) T {
var z T
for _, i := range is {
Expand Down