🏡 index : quine-global/quintodrome.git

package core_test

import (
	"archive/zip"
	"bytes"
	"context"
	"errors"
	"io"
	"strings"

	"github.com/Masterminds/squirrel"
	"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"
	"github.com/stretchr/testify/mock"
)

var _ = Describe("Archiver", func() {
	var (
		arch core.Archiver
		ms   *mockMediaStreamer
		dc   *fakeDecider
		ds   *mockDataStore
		sh   *mockShare
		ca   *mockCoverArt
	)

	BeforeEach(func() {
		ms = &mockMediaStreamer{}
		dc = &fakeDecider{}
		sh = &mockShare{}
		ds = &mockDataStore{}
		ca = &mockCoverArt{images: map[string][]byte{}}
		arch = core.NewArchiver(ms, dc, ds, sh, ca)
	})

	Context("ZipAlbum", func() {
		It("zips an album correctly", func() {
			mfs := model.MediaFiles{
				{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
				{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
			}

			mfRepo := &mockMediaFileRepository{}
			mfRepo.On("GetAll", []model.QueryOptions{{
				Filters: squirrel.Eq{"album_id": "1"},
				Sort:    "album",
			}}).Return(mfs, nil)

			ds.On("MediaFile").Return(mfRepo)
			ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)

			out := new(bytes.Buffer)
			err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
			Expect(err).To(BeNil())

			zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
			Expect(err).To(BeNil())

			Expect(len(zr.File)).To(Equal(2))
			Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
			Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
		})

		It("streams the request resolved by the transcode decider and names the entry after its format", func() {
			mfRepo := &mockMediaFileRepository{}
			mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil)
			ds.On("MediaFile").Return(mfRepo)
			resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}
			dc.resolved = &resolved
			ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once()

			out := new(bytes.Buffer)
			Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
			ms.AssertExpectations(GinkgoT())

			zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
			Expect(err).ToNot(HaveOccurred())
			Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus"))
		})
	})

	Context("ZipArtist", func() {
		It("zips an artist's albums correctly", func() {
			mfs := model.MediaFiles{
				{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
				{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
			}

			mfRepo := &mockMediaFileRepository{}
			mfRepo.On("GetAll", []model.QueryOptions{{
				Filters: squirrel.And{
					persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
					squirrel.Eq{"missing": false},
				},
				Sort: "album",
			}}).Return(mfs, nil)

			ds.On("MediaFile").Return(mfRepo)
			ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)

			out := new(bytes.Buffer)
			err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
			Expect(err).To(BeNil())

			zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
			Expect(err).To(BeNil())

			Expect(len(zr.File)).To(Equal(2))
			Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
			Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
		})

		When("albums that share a name", func() {
			BeforeEach(func() {
				DeferCleanup(configtest.SetupConfig())
			})

			// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
			zipArtistEntries := func(mfs model.MediaFiles) []string {
				mfRepo := &mockMediaFileRepository{}
				mfRepo.On("GetAll", mock.Anything).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)

				out := new(bytes.Buffer)
				Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
				zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
				Expect(err).To(BeNil())
				names := make([]string, len(zr.File))
				for i, f := range zr.File {
					names[i] = f.Name
				}
				return names
			}

			It("keeps the albums in query order", func() {
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"},
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
					{Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"},
				})
				Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"}))
			})

			It("suffixes the year when it tells the albums apart", func() {
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
					{Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
				})
				Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"}))
			})

			It("prefers the release year, so reissues of the same original are told apart", func() {
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011},
					{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996},
				})
				Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"}))
			})

			It("names the folder after the full album name", func() {
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
						Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
						Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
				})
				Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"}))
			})

			It("prefers the album version over the year when it is not part of the name", func() {
				conf.Server.Subsonic.AppendAlbumVersion = false
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
						Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
						Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
				})
				Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
			})

			It("leaves the one album without the field unsuffixed", func() {
				conf.Server.Subsonic.AppendAlbumVersion = false
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
						Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
				})
				Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
			})

			It("skips a field that is empty on more than one album", func() {
				conf.Server.Subsonic.AppendAlbumVersion = false
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
					{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010,
						Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
				})
				Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"}))
			})

			It("skips a field that is the same on every album", func() {
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"},
				})
				Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"}))
			})

			It("falls back to the album id when nothing differs", func() {
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001},
				})
				Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"}))
			})

			It("treats names that sanitize to the same folder as a clash", func() {
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005},
				})
				Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"}))
			})

			It("sanitizes the suffix", func() {
				conf.Server.Subsonic.AppendAlbumVersion = false
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
				})
				Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"}))
			})

			It("leaves the folder name alone when only one album has it", func() {
				names := zipArtistEntries(model.MediaFiles{
					{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
					{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005},
				})
				Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"}))
			})
		})
	})

	Context("when the transcode limiter rejects a file", func() {
		It("aborts the archive instead of continuing with empty entries", func() {
			mfs := model.MediaFiles{
				{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
				{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
			}

			mfRepo := &mockMediaFileRepository{}
			mfRepo.On("GetAll", []model.QueryOptions{{
				Filters: squirrel.Eq{"album_id": "1"},
				Sort:    "album",
			}}).Return(mfs, nil)
			ds.On("MediaFile").Return(mfRepo)

			ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
				Return(nil, stream.ErrTooManyTranscodes).Once()

			out := new(bytes.Buffer)
			err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
			Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
			// NewStream should only have been called once: the loop must bail
			// out on the rejection instead of trying every remaining track.
			ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
		})
	})

	Context("ZipShare", func() {
		It("zips a share correctly", func() {
			mfs := model.MediaFiles{
				{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
				{ID: "2", Path: "test_data/02 - track2.mp3", Suffix: "mp3", Artist: "Artist 2", Title: "track2"},
			}

			share := &model.Share{
				ID:           "1",
				Downloadable: true,
				Format:       "mp3",
				MaxBitRate:   128,
				Tracks:       mfs,
			}

			ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)

			out := new(bytes.Buffer)
			err := arch.ZipShare(context.Background(), share, out)
			Expect(err).To(BeNil())

			// Share.Load records a visit; re-loading here would double-count
			// every download.
			sh.AssertNotCalled(GinkgoT(), "Load", mock.Anything, mock.Anything)

			zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
			Expect(err).To(BeNil())

			Expect(len(zr.File)).To(Equal(2))
			Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3"))
			Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))

		})
	})

	Context("ZipPlaylist", func() {
		It("names the entries and the M3U lines after the resolved format", func() {
			pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{
				{MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}},
			}}
			plRepo := &mockPlaylistRepository{}
			plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
			ds.On("Playlist").Return(plRepo)
			dc.resolved = &stream.Request{Format: "opus", BitRate: 128}
			ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil)

			out := new(bytes.Buffer)
			Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())

			zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
			Expect(err).ToNot(HaveOccurred())
			Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus"))
			m3u, err := zr.File[1].Open()
			Expect(err).ToNot(HaveOccurred())
			defer m3u.Close()
			content, err := io.ReadAll(m3u)
			Expect(err).ToNot(HaveOccurred())
			Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus"))
		})

		It("zips a playlist correctly", func() {
			tracks := []model.PlaylistTrack{
				{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
				{MediaFile: model.MediaFile{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 2", Title: "track2"}},
			}

			pls := &model.Playlist{
				ID:     "1",
				Name:   "Test Playlist",
				Tracks: tracks,
			}

			plRepo := &mockPlaylistRepository{}
			plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
			ds.On("Playlist").Return(plRepo)
			ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)

			out := new(bytes.Buffer)
			err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
			Expect(err).To(BeNil())

			zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
			Expect(err).To(BeNil())

			Expect(len(zr.File)).To(Equal(3))
			Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
			Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
			Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u"))

			// Verify M3U content
			m3uFile, err := zr.File[2].Open()
			Expect(err).To(BeNil())
			defer m3uFile.Close()

			m3uContent, err := io.ReadAll(m3uFile)
			Expect(err).To(BeNil())

			expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n"
			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
}

func (m *mockDataStore) MediaFile() model.MediaFileRepository {
	args := m.Called()
	return args.Get(0).(model.MediaFileRepository)
}

func (m *mockDataStore) Playlist() model.PlaylistRepository {
	args := m.Called()
	return args.Get(0).(model.PlaylistRepository)
}

func (m *mockDataStore) Library() model.LibraryRepository {
	return &mockLibraryRepository{}
}

type mockLibraryRepository struct {
	mock.Mock
	model.LibraryRepository
}

func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
	return "/music", nil
}

type mockMediaFileRepository struct {
	mock.Mock
	model.MediaFileRepository
}

func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
	args := m.Called(options)
	return args.Get(0).(model.MediaFiles), args.Error(1)
}

type mockPlaylistRepository struct {
	mock.Mock
	model.PlaylistRepository
}

func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
	args := m.Called(id, refreshSmartPlaylists, includeMissing)
	return args.Get(0).(*model.Playlist), args.Error(1)
}

type mockMediaStreamer struct {
	mock.Mock
	stream.MediaStreamer
}

func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
	args := m.Called(ctx, mf, req)
	if args.Error(1) != nil {
		return nil, args.Error(1)
	}
	return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}

// fakeDecider echoes the legacy format/bitrate unless a resolved request is set.
type fakeDecider struct {
	stream.TranscodeDecider
	resolved *stream.Request
}

func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
	if f.resolved != nil {
		return *f.resolved
	}
	return stream.Request{Format: format, BitRate: bitRate, Offset: offset}
}

type mockShare struct {
	mock.Mock
	core.Share
}

func (m *mockShare) Load(ctx context.Context, id string) (*model.Share, error) {
	args := m.Called(ctx, id)
	return args.Get(0).(*model.Share), args.Error(1)
}