package e2e
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"testing/fstest"
"time"
"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/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
"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/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/tests/harness"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSubsonicE2E(t *testing.T) {
tests.Init(t, false)
defer db.Close(t.Context())
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Subsonic API E2E Suite")
}
type _t = map[string]any
var template = storagetest.Template
var track = storagetest.Track
var file = storagetest.File
const (
mbidBeatlesArtist = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"
mbidAbbeyRoadAlbum = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
mbidAbbeyRoadRelGroup = "d4c3b2a1-f6e5-4b7a-9d8c-1f0e3a2b5c4d"
mbidComeTogether = "11111111-1111-4111-a111-111111111111"
mbidComeTogetherRec = "22222222-2222-4222-a222-222222222222"
mbidSomething = "33333333-3333-4333-a333-333333333333"
mbidSomethingRec = "44444444-4444-4444-a444-444444444444"
)
func lyricFixture(name string) string {
data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name))
Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name)
return string(data)
}
const firstFixtureLine = "Should auld acquaintance be forgot,"
var (
ctx context.Context
ds *tests.MockDataStore
router *subsonic.Router
streamerSpy *harness.SpyStreamer
goldenDB *harness.DB
lib model.Library
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
}
regularUser = model.User{
ID: "regular-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
}
)
func buildTestFS() storagetest.FakeFS {
abbeyRoad := template(_t{
"albumartist": "The Beatles",
"artist": "The Beatles",
"album": "Abbey Road",
"year": 1969,
"genre": "Rock",
"musicbrainz_artistid": mbidBeatlesArtist,
"musicbrainz_albumartistid": mbidBeatlesArtist,
"musicbrainz_albumid": mbidAbbeyRoadAlbum,
"musicbrainz_releasegroupid": mbidAbbeyRoadRelGroup,
})
help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"})
ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"})
cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"})
tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"}
lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"})
return harness.CreateFS(fstest.MapFS{
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
_t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec, "bpm": 120})),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
_t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})),
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")),
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
"Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")),
"CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")),
"Test/Transcode Formats/01 - TC FLAC Standard.flac": file(tcBase, _t{
"title": "TC FLAC Standard", "track": 1, "suffix": "flac",
"bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(240),
}),
"Test/Transcode Formats/02 - TC FLAC HiRes.flac": file(tcBase, _t{
"title": "TC FLAC HiRes", "track": 2, "suffix": "flac",
"bitrate": 3000, "samplerate": 96000, "bitdepth": 24, "channels": 2, "duration": int64(180),
}),
"Test/Transcode Formats/03 - TC ALAC Track.m4a": file(tcBase, _t{
"title": "TC ALAC Track", "track": 3, "suffix": "m4a",
"bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(200),
}),
"Test/Transcode Formats/04 - TC DSD Track.dsf": file(tcBase, _t{
"title": "TC DSD Track", "track": 4, "suffix": "dsf",
"bitrate": 5645, "samplerate": 2822400, "bitdepth": 1, "channels": 2, "duration": int64(300),
}),
"Test/Transcode Formats/05 - TC Opus Track.opus": file(tcBase, _t{
"title": "TC Opus Track", "track": 5, "suffix": "opus",
"bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(210),
}),
"Test/Transcode Formats/06 - TC MKA Opus.mka": file(tcBase, _t{
"title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus",
"bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220),
}),
"Test/Transcode Formats/07 - TC FLAC Multichannel.flac": file(tcBase, _t{
"title": "TC FLAC Multichannel", "track": 7, "suffix": "flac",
"bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180),
}),
"Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC",
_t{"lyrics": lyricFixture("auld-lang-syne.elrc")})),
"Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain",
_t{"lyrics": lyricFixture("auld-lang-syne.txt")})),
"Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML",
_t{"lyrics": lyricFixture("auld-lang-syne.ttml")})),
"Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")),
"Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()},
"Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")),
"Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()},
"Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")),
"Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()},
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
})
}
func createUser(id, username, name string, isAdmin bool) model.User {
user := model.User{
ID: id,
UserName: username,
Name: name,
IsAdmin: isAdmin,
NewPassword: "password",
}
Expect(ds.User().Put(ctx, &user)).To(Succeed())
Expect(ds.User().SetUserLibraries(ctx, user.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := ds.User().FindByUsername(ctx, user.UserName)
Expect(err).ToNot(HaveOccurred())
user.Libraries = loadedUser.Libraries
return user
}
func doReq(endpoint string, params ...string) *responses.Subsonic {
return doReqWithUser(adminUser, endpoint, params...)
}
func doReqWithUser(user model.User, endpoint string, params ...string) *responses.Subsonic {
w := httptest.NewRecorder()
r := buildReq(user, endpoint, params...)
router.ServeHTTP(w, r)
return parseJSONResponse(w)
}
func doRawReq(endpoint string, params ...string) *httptest.ResponseRecorder {
return doRawReqWithUser(adminUser, endpoint, params...)
}
func doRawReqWithUser(user model.User, endpoint string, params ...string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := buildReq(user, endpoint, params...)
router.ServeHTTP(w, r)
return w
}
func buildReq(user model.User, endpoint string, params ...string) *http.Request {
if len(params)%2 != 0 {
panic("buildReq: odd number of parameters")
}
q := url.Values{}
q.Add("u", user.UserName)
q.Add("p", "password")
q.Add("v", "1.16.1")
q.Add("c", "test-client")
q.Add("f", "json")
for i := 0; i < len(params); i += 2 {
q.Add(params[i], params[i+1])
}
return httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil)
}
func buildPostReq(user model.User, endpoint string, body string, params ...string) *http.Request {
getReq := buildReq(user, endpoint, params...)
r := httptest.NewRequest("POST", getReq.URL.RequestURI(), bytes.NewReader([]byte(body)))
r.Header.Set("Content-Type", "application/json")
return r
}
func doPostReq(endpoint string, body string, params ...string) *responses.Subsonic {
w := httptest.NewRecorder()
r := buildPostReq(adminUser, endpoint, body, params...)
router.ServeHTTP(w, r)
return parseJSONResponse(w)
}
func doRawPostReq(endpoint string, body string, params ...string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := buildPostReq(adminUser, endpoint, body, params...)
router.ServeHTTP(w, r)
return w
}
func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic {
Expect(w.Code).To(Equal(http.StatusOK))
var wrapper responses.JsonWrapper
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
return &wrapper.Subsonic
}
type noopArtwork struct{}
func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) {
return nil, model.ErrNotFound
}
func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (*artwork.Image, error) {
return &artwork.Image{ReadCloser: io.NopCloser(io.LimitReader(nil, 0))}, nil
}
type noopArchiver struct{}
func (n noopArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipShare(context.Context, *model.Share, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
type noopProvider struct{}
func (n noopProvider) UpdateAlbumInfo(_ context.Context, _ string) (*model.Album, error) {
return &model.Album{}, nil
}
func (n noopProvider) UpdateArtistInfo(_ context.Context, _ string, _ int, _ bool) (*model.Artist, error) {
return &model.Artist{}, nil
}
func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n noopProvider) RefreshInfo(context.Context, model.Kind, string) error {
return nil
}
var (
_ artwork.Artwork = noopArtwork{}
_ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{}
)
var _ = BeforeSuite(func() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
conf.Server.MusicFolder = "fake:///music"
conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml"
conf.Server.DevExternalScanner = false
buildTestFS()
goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser)
lib = goldenDB.Library
ctx = request.WithUser(GinkgoT().Context(), adminUser)
})
var _ = AfterSuite(func() {
db.Close(ctx)
})
func setupTestDB() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
DeferCleanup(configtest.SetupConfig())
DeferCleanup(func() {
Eventually(scanner.IsScanning).Should(BeFalse())
})
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false
conf.Server.DevEnableMediaFileProbe = false
goldenDB.Restore()
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds)
streamerSpy = &harness.SpyStreamer{}
decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{})
s := scanner.New(ctx, ds, events.NoopBroker(),
playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance())
router = subsonic.New(
ds,
noopArtwork{},
streamerSpy,
noopArchiver{},
core.NewPlayers(ds),
noopProvider{},
s,
events.NoopBroker(),
playlists.NewPlaylists(ds, artwork.NewUploader(ds)),
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
lyrics.NewLyrics(ds, nil),
decider,
nil,
)
}