diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 43ba808d0..cb06cc047 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -95,7 +95,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share) + archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) players := core.NewPlayers(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) @@ -152,7 +152,7 @@ func CreatePublicRouter() *public.Router { transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share) + archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) return router } diff --git a/core/archiver.go b/core/archiver.go index 6406ca075..33236d889 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -7,20 +7,27 @@ import ( "errors" "fmt" "io" + "net/http" "os" + "path" "path/filepath" "strconv" "strings" + "time" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) +const archiveCoverArtSize = 500 + type Archiver interface { ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error @@ -28,18 +35,19 @@ type Archiver interface { ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver { - return &archiver{ds: ds, ms: ms, shares: shares} +func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { + return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork} } type archiver struct { - ds model.DataStore - ms stream.MediaStreamer - shares Share + ds model.DataStore + ms stream.MediaStreamer + shares Share + artwork artwork.Artwork } func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}) + return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{}) } func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { @@ -49,10 +57,11 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist), squirrel.Eq{"missing": false}, } - return a.zipAlbums(ctx, id, format, bitrate, out, filter) + return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID()) } -func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error { +// rootArt, when set, is added to the archive root. +func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error { mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"}) if err != nil { log.Error(ctx, "Error loading mediafiles from artist", "id", id, err) @@ -80,7 +89,10 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr return addErr } } + // After the tracks, so a slow artwork lookup doesn't delay the first bytes. + a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder) } + a.addCoverArtToZip(ctx, z, rootArt, "") err = z.Close() if err != nil { log.Error(ctx, "Error closing zip file", "id", id, err) @@ -170,7 +182,10 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) return model.ErrNotAuthorized } log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks)) - return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) + // The share is the authorization (as in the public image handler): an anonymous lookup would + // hide a private playlist. Only the cover read is elevated. + coverCtx := request.WithUser(ctx, model.User{IsAdmin: true}) + return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false) } func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { @@ -181,10 +196,10 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi } mfs := pls.MediaFiles() log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs)) - return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true) + return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true) } -func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error { +func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error { z := createZipWriter(out, format, bitrate) zippedMfs := make(model.MediaFiles, len(mfs)) @@ -199,6 +214,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st mf.Path = file zippedMfs[idx] = mf } + a.addCoverArtToZip(coverCtx, z, coverArt, "") // Add M3U file if requested if addM3U && len(zippedMfs) > 0 { @@ -276,3 +292,61 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med return nil } + +// addCoverArtToZip adds the cover as dir/folder.. Errors are logged, never returned. +func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) { + if artID.ID == "" { + return + } + // Buffered so a failed read leaves no empty entry. + data, err := a.readCoverArt(ctx, artID) + if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) { + log.Debug(ctx, "No cover art to add to zip", "artID", artID) + return + } + if err != nil { + log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err) + return + } + ext := coverArtExtension(data) + if ext == "" { + log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID) + return + } + w, err := z.CreateHeader(&zip.FileHeader{ + Name: path.Join(dir, "folder."+ext), + Modified: time.Now(), + Method: zip.Store, + }) + if err != nil { + log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err) + return + } + if _, err = w.Write(data); err != nil { + log.Warn(ctx, "Error zipping cover art", "artID", artID, err) + } +} + +func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) { + img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false) + if err != nil { + return nil, err + } + defer img.Close() + return io.ReadAll(img) +} + +// Resizing may re-encode the image, so the type comes from its bytes. +func coverArtExtension(data []byte) string { + switch http.DetectContentType(data) { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + } + return "" +} diff --git a/core/archiver_test.go b/core/archiver_test.go index 86833717a..9dab44cef 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + "errors" "io" "strings" @@ -11,8 +12,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/persistence" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -25,13 +28,15 @@ var _ = Describe("Archiver", func() { ms *mockMediaStreamer ds *mockDataStore sh *mockShare + ca *mockCoverArt ) BeforeEach(func() { ms = &mockMediaStreamer{} sh = &mockShare{} ds = &mockDataStore{} - arch = core.NewArchiver(ms, ds, sh) + ca = &mockCoverArt{images: map[string][]byte{}} + arch = core.NewArchiver(ms, ds, sh, ca) }) Context("ZipAlbum", func() { @@ -332,8 +337,179 @@ var _ = Describe("Archiver", func() { Expect(string(m3uContent)).To(Equal(expectedM3U)) }) }) + Context("cover art", func() { + var ( + jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg") + pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png") + ) + + mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) { + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) + } + + It("adds the album cover to the album folder", func() { + ca.images["al-1"] = jpegData + mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1}, + }) + + out := new(bytes.Buffer) + Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(2)) + Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData)) + Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false})) + }) + + It("adds the artist image to the root and each album cover to its folder", func() { + ca.images["ar-1"] = pngData + ca.images["al-1"] = jpegData + ca.images["al-2"] = jpegData + mockAlbumTracks(squirrel.And{ + persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), + squirrel.Eq{"missing": false}, + }, model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1}, + }) + + out := new(bytes.Buffer) + Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(5)) + Expect(files).To(HaveKeyWithValue("folder.png", pngData)) + Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData)) + Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData)) + }) + + It("puts each same-named album's cover in that album's own folder", func() { + ca.images["al-1"] = jpegData + ca.images["al-2"] = pngData + mockAlbumTracks(squirrel.And{ + persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), + squirrel.Eq{"missing": false}, + }, model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1}, + }) + + out := new(bytes.Buffer) + Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData)) + Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData)) + }) + + It("adds the playlist cover to the root", func() { + ca.images["pl-1"] = jpegData + plRepo := &mockPlaylistRepository{} + plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{ + ID: "1", + Name: "Test Playlist", + Tracks: []model.PlaylistTrack{ + {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}}, + }, + }, nil) + ds.On("Playlist", mock.Anything).Return(plRepo) + ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) + + out := new(bytes.Buffer) + Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(3)) + Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData)) + Expect(files).To(HaveKey("Test Playlist.m3u")) + }) + + It("adds the shared item's cover to the root, even for a private playlist", func() { + ca.images["pl-10"] = jpegData + ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) + share := &model.Share{ + ID: "1", + Downloadable: true, + Format: "mp3", + MaxBitRate: 128, + ResourceType: "playlist", + ResourceIDs: "10", + Tracks: model.MediaFiles{ + {ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"}, + }, + } + + out := new(bytes.Buffer) + Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(2)) + Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData)) + Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true})) + }) + + It("still builds the archive when the cover cannot be read", func() { + ca.err = errors.New("boom") + mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + }) + + out := new(bytes.Buffer) + Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(1)) + Expect(files).To(HaveKey("Album/01 - track1.mp3")) + }) + }) }) +func readZip(out *bytes.Buffer) map[string][]byte { + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).ToNot(HaveOccurred()) + files := make(map[string][]byte, len(zr.File)) + for _, f := range zr.File { + r, err := f.Open() + Expect(err).ToNot(HaveOccurred()) + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + _ = r.Close() + files[f.Name] = data + } + return files +} + +type coverRequest struct { + id string + size int + square bool + admin bool +} + +type mockCoverArt struct { + artwork.Artwork + images map[string][]byte + err error + requests []coverRequest +} + +func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) { + user, _ := request.UserFrom(ctx) + m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin}) + if m.err != nil { + return nil, m.err + } + data, ok := m.images[artID.String()] + if !ok { + return nil, artwork.ErrUnavailable + } + return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil +} + type mockDataStore struct { mock.Mock model.DataStore diff --git a/core/artwork/folders_artist.go b/core/artwork/folders_artist.go index 1ca1ce034..efef42f81 100644 --- a/core/artwork/folders_artist.go +++ b/core/artwork/folders_artist.go @@ -14,7 +14,6 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -169,8 +168,9 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu folderPath = filepath.Dir(folderPath) } - // TODO: Hacky, but the easiest way to get the folder ID ATM - libPath := core.AbsolutePath(ctx, ds, libID, "") + // Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".". + libPath, _ := ds.Library(ctx).GetPath(libID) + libPath = filepath.Join(libPath) folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, diff --git a/core/artwork/folders_artist_paths_test.go b/core/artwork/folders_artist_paths_test.go index cc8af3e63..127661891 100644 --- a/core/artwork/folders_artist_paths_test.go +++ b/core/artwork/folders_artist_paths_test.go @@ -6,7 +6,6 @@ import ( "path/filepath" "time" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -47,11 +46,11 @@ var _ = Describe("loadArtistFolder", func() { BeforeEach(func() { ctx = context.Background() - DeferCleanup(stubCoreAbsolutePath()) - updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute) repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}} - ds = &tests.MockDataStore{MockedFolder: repo} + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 1, Path: filepath.FromSlash("/music")}}) + ds = &tests.MockDataStore{MockedFolder: repo, MockedLibrary: libRepo} albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}} }) @@ -107,11 +106,3 @@ var _ = Describe("loadArtistFolder", func() { Expect(upd).To(BeZero()) }) }) - -func stubCoreAbsolutePath() func() { - original := core.AbsolutePath - core.AbsolutePath = func(context.Context, model.DataStore, int, string) string { - return filepath.FromSlash("/music") - } - return func() { core.AbsolutePath = original } -}