diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 0d0e5099..a8c2f081 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -199,6 +199,7 @@ func main() { }, tagReader, *confExcludePattern, + cacheDirCovers, ) podcast := podcast.New(dbc, *confPodcastPath, tagReader) transcoder := transcode.NewCachingTranscoder( diff --git a/go.mod b/go.mod index 42957579..f0fd6398 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3ecb1315..ffee3907 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/mockfs/mockfs.go b/mockfs/mockfs.go index a109f80c..e9603476 100644 --- a/mockfs/mockfs.go +++ b/mockfs/mockfs.go @@ -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, diff --git a/scanner/scanner.go b/scanner/scanner.go index 77e87111..e1bdaf7a 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -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) @@ -55,6 +56,7 @@ func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSet tagReader: tagReader, excludePattern: excludePatternRegExp, scanning: new(int32), + cacheCoverPath: cacheCoverPath, } } @@ -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 diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 19e72e08..4fd11eac 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -12,7 +12,6 @@ import ( "path/filepath" "time" - "github.com/disintegration/imaging" "github.com/jinzhu/gorm" "go.senan.xyz/gonic/db" @@ -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" ) @@ -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 } @@ -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) diff --git a/server/ctrlsubsonic/spec/construct_by_folder.go b/server/ctrlsubsonic/spec/construct_by_folder.go index f8f35d34..30b9ec90 100644 --- a/server/ctrlsubsonic/spec/construct_by_folder.go +++ b/server/ctrlsubsonic/spec/construct_by_folder.go @@ -19,6 +19,7 @@ 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 @@ -26,9 +27,6 @@ func NewAlbumByFolder(f *db.Album) *Album { if f.AlbumRating != nil { a.UserRating = f.AlbumRating.Rating } - if f.Cover != "" { - a.CoverID = f.SID() - } return a } @@ -40,6 +38,7 @@ 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 @@ -47,9 +46,6 @@ func NewTCAlbumByFolder(f *db.Album) *TrackChild { if f.AlbumRating != nil { trCh.UserRating = f.AlbumRating.Rating } - if f.Cover != "" { - trCh.CoverID = f.SID() - } return trCh } @@ -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 } diff --git a/server/ctrlsubsonic/spec/construct_by_tags.go b/server/ctrlsubsonic/spec/construct_by_tags.go index 97b8332f..a686d50d 100644 --- a/server/ctrlsubsonic/spec/construct_by_tags.go +++ b/server/ctrlsubsonic/spec/construct_by_tags.go @@ -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 @@ -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 diff --git a/tags/tagcommon/tagcommmon.go b/tags/tagcommon/tagcommmon.go index 10da58d1..2eac4ea8 100644 --- a/tags/tagcommon/tagcommmon.go +++ b/tags/tagcommon/tagcommmon.go @@ -2,6 +2,12 @@ package tagcommon import ( "errors" + "fmt" + "io" + "path/filepath" + + "github.com/disintegration/imaging" + "github.com/sentriz/audiotags" ) var ErrUnsupported = errors.New("filetype unsupported") @@ -39,8 +45,45 @@ 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 +} + +// 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