mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
* fix(share): reuse cached transcodes when streaming from share links Public share streams built the stream request with only the share's format and bit rate, leaving sample rate, bit depth and channels at zero. Regular playback resolves those through the transcode decider (e.g. 48000 Hz for Opus), and they are part of the transcoding cache key, so a track already transcoded during normal playback was transcoded again into a separate, identical cache entry when played through a share link. The public router now resolves share stream requests with the same TranscodeDecider.ResolveRequest used by the Subsonic stream endpoint, so both paths produce the same request and share cache entries. Fixes #6261 * fix(archiver): reuse cached transcodes when zipping downloads Zip downloads (album, artist, playlist and share) built the stream request with only the format and bit rate, leaving sample rate, bit depth and channels at zero. Those are part of the transcoding cache key, so a track already transcoded for playback was transcoded again into a separate cache entry when downloaded in a zip, and vice versa. The archiver now resolves each request with TranscodeDecider.ResolveRequest, the same as single-song downloads and streams. This also applies the decider's defaults, so a zip requested without a bit rate uses the target format's default bit rate instead of leaving it to ffmpeg. * fix(archiver): name zip entries after the resolved transcoding format The transcode decider can pick a different format than the one requested (for example a player's forced transcoding, or a fallback to the default downsampling format when the requested one can't be produced). Zip entry names and the playlist M3U were still built from the requested format, so an entry could end in .mp3 or .flac while holding Opus data. Each track's request is now resolved before its entry name is built, and the name uses the resolved format.
638 lines
24 KiB
Go
638 lines
24 KiB
Go
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)
|
|
}
|