navidrome/core/archiver_test.go
Deluan Quintão 752b38609c
feat(artist): add Share and Download actions to the Artist detail page (#5944)
* feat(ui): add Share button to artist detail page

* feat(ui): add Download button to artist detail page

* fix(ui): scope artist share/download to album-artist content

Gate the artist Share/Download actions on album-artist stats and show the
album-artist size, since ZipArtist and the share query only cover
album_artist_id songs. Previously the total (role-inclusive) size was shown
and guest-only artists could produce an empty archive. Applies to the artist
toolbar, the shared context menu, and the download dialog title.

* fix: match artist download/share to album-artist participation

ZipArtist and the artist share query filtered the deprecated album_artist_id
column, which only stores the first album artist of a track. Secondary
album-artists (co-credited but not first) got an empty download/share even
though the UI offered it. Filter by the album-artist role participation
instead, matching the artist's album-artist stats used to gate the actions.
Also cover the artist-specific size branch of the download dialog.

* fix(share): scope artist shares to the owner's libraries

The artist share query broadened to album-artist participation, which could
pull a secondary album artist's tracks from libraries the (non-admin) share
owner cannot access into the public share. Load the artist share as the owner
so their library access is applied, mirroring how playlist shares already work.
Adds a repository test covering co-album-artist inclusion and library scoping.

* test(share): assert album participation branch of artist shares

Link the co-album-artist fixtures to albums and assert share.Albums (used by
Subsonic getShares) includes the accessible album and excludes the one in a
library the owner cannot access, so the album participation + scoping branch
is covered too.

* fix: exclude missing files from artist download/share actions

An artist's stats still count files that went missing, so the toolbar/context
menu could offer Download/Share for an artist whose files are all gone, while
the share query (missing=false) returns nothing and downloads open dead paths.
Hide the actions when the artist is missing and exclude missing files from
ZipArtist, matching the share semantics.

* refactor: dedupe artist download-size and share-owner lookups

Extract the 'album-artist download size (or none when missing)' rule into a
single artistDownloadSize() helper shared by the toolbar, context menu, and
download dialog, and factor the duplicated share-owner context lookup into a
shareRepository.ownerContext() method used by both the artist and playlist
share cases.

* refactor(ui): move artistDownloadSize helper to common

utils is for domain-agnostic, potentially portable code; this helper is
Navidrome-specific (artist stats shape), so it belongs in common. Consumers
import it directly from common/artist to avoid pulling in the common barrel.
2026-08-12 11:59:56 -04:00

271 lines
8.7 KiB
Go

package core_test
import (
"archive/zip"
"bytes"
"context"
"io"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"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
ds *mockDataStore
sh *mockShare
)
BeforeEach(func() {
ms = &mockMediaStreamer{}
sh = &mockShare{}
ds = &mockDataStore{}
arch = core.NewArchiver(ms, ds, sh)
})
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", mock.Anything).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"))
})
})
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", mock.Anything).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"))
})
})
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", mock.Anything).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("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", mock.Anything).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))
})
})
})
type mockDataStore struct {
mock.Mock
model.DataStore
}
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
args := m.Called(ctx)
return args.Get(0).(model.MediaFileRepository)
}
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
args := m.Called(ctx)
return args.Get(0).(model.PlaylistRepository)
}
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
return &mockLibraryRepository{}
}
type mockLibraryRepository struct {
mock.Mock
model.LibraryRepository
}
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
return "/music", nil
}
type mockMediaFileRepository struct {
mock.Mock
model.MediaFileRepository
}
func (m *mockMediaFileRepository) GetAll(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(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
}
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)
}