Subsonic: browsing endpoints (getMusicFolders, getIndexes, getMusicDirectory, getGenres, getArtists/Artist, getAlbum, getSong, getVideos/VideoInfo, getArtistInfo/2, getAlbumInfo/2, getSimilarSongs/2, getTopSongs)
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// seedLibrary scans a small real (ffmpeg-generated) library into s so
|
||||
// browsing endpoints have real rows to return. Skips the test if
|
||||
// ffmpeg/ffprobe aren't installed.
|
||||
func seedLibrary(t *testing.T, s *Server) (folder *database.MusicFolder) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed")
|
||||
}
|
||||
if _, err := exec.LookPath("ffprobe"); err != nil {
|
||||
t.Skip("ffprobe not installed")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
songDir := filepath.Join(dir, "Test Artist", "Test Album")
|
||||
if err := os.MkdirAll(songDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, title := range []string{"First Song", "Second Song"} {
|
||||
out := filepath.Join(songDir, title+".mp3")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
||||
"-metadata", "title="+title,
|
||||
"-metadata", "artist=Test Artist",
|
||||
"-metadata", "album=Test Album",
|
||||
"-metadata", "genre=Rock",
|
||||
"-metadata", "track="+strconv.Itoa(i+1),
|
||||
"-metadata", "date=2021",
|
||||
"-codec:a", "libmp3lame", "-b:a", "64k",
|
||||
out)
|
||||
if combined, err := cmd.CombinedOutput(); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("ffmpeg: %v\n%s", err, combined)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
f, err := database.CreateMusicFolder(s.DB, "Test Music", dir, "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Scanner.ScanFolder(context.Background(), f.ID); err != nil {
|
||||
t.Fatalf("ScanFolder: %v", err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestGetMusicFoldersAndGenresAndArtists(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("f", "json")
|
||||
|
||||
resp := getPath(t, srv.URL, "/rest/getMusicFolders.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
folders := j["musicFolders"].(map[string]any)["musicFolder"].([]any)
|
||||
if len(folders) != 1 {
|
||||
t.Fatalf("musicFolders = %v, want 1", folders)
|
||||
}
|
||||
|
||||
resp = getPath(t, srv.URL, "/rest/getGenres.view", q)
|
||||
j = decodeJSON(t, resp)
|
||||
genres := j["genres"].(map[string]any)["genre"].([]any)
|
||||
if len(genres) != 1 {
|
||||
t.Fatalf("genres = %v, want 1", genres)
|
||||
}
|
||||
|
||||
resp = getPath(t, srv.URL, "/rest/getArtists.view", q)
|
||||
j = decodeJSON(t, resp)
|
||||
indexes := j["artists"].(map[string]any)["index"].([]any)
|
||||
if len(indexes) == 0 {
|
||||
t.Fatalf("artists index = %v, want at least 1 letter group", indexes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtistAndAlbumAndSong(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
srv := httptest.NewServer(s.Router())
|
||||
defer srv.Close()
|
||||
|
||||
artists, err := database.ListArtists(db)
|
||||
if err != nil || len(artists) == 0 {
|
||||
t.Fatalf("ListArtists: %v %v", artists, err)
|
||||
}
|
||||
artistID := artists[0].ID
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("f", "json")
|
||||
q.Set("id", artistID)
|
||||
resp := getPath(t, srv.URL, "/rest/getArtist.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
artist := j["artist"].(map[string]any)
|
||||
albums := artist["album"].([]any)
|
||||
if len(albums) != 1 {
|
||||
t.Fatalf("albums = %v, want 1", albums)
|
||||
}
|
||||
albumID := albums[0].(map[string]any)["id"].(string)
|
||||
|
||||
q.Set("id", albumID)
|
||||
resp = getPath(t, srv.URL, "/rest/getAlbum.view", q)
|
||||
j = decodeJSON(t, resp)
|
||||
album := j["album"].(map[string]any)
|
||||
songs := album["song"].([]any)
|
||||
if len(songs) != 2 {
|
||||
t.Fatalf("songs = %v, want 2", songs)
|
||||
}
|
||||
songID := songs[0].(map[string]any)["id"].(string)
|
||||
|
||||
q.Set("id", songID)
|
||||
resp = getPath(t, srv.URL, "/rest/getSong.view", q)
|
||||
j = decodeJSON(t, resp)
|
||||
song := j["song"].(map[string]any)
|
||||
if song["title"] == nil {
|
||||
t.Fatalf("song missing title: %v", song)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAlbumNotFound(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", "al_doesnotexist")
|
||||
resp := getPath(t, srv.URL, "/rest/getAlbum.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
||||
t.Fatalf("expected error 70, got %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAlbumMissingID(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
resp := getPath(t, srv.URL, "/rest/getAlbum.view", baseParams("alice", "password123"))
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
|
||||
t.Fatalf("expected error 10, got %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIndexesAndMusicDirectory(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
resp := getPath(t, srv.URL, "/rest/getIndexes.view", q)
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
Indexes struct {
|
||||
Index []struct {
|
||||
Artist []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
} `xml:"artist"`
|
||||
} `xml:"index"`
|
||||
} `xml:"indexes"`
|
||||
}
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(out.Indexes.Index) == 0 || len(out.Indexes.Index[0].Artist) == 0 {
|
||||
t.Fatalf("expected at least one artist directory, got %+v", out)
|
||||
}
|
||||
dirID := out.Indexes.Index[0].Artist[0].ID
|
||||
|
||||
q2 := baseParams("alice", "password123")
|
||||
q2.Set("id", dirID)
|
||||
resp2 := getPath(t, srv.URL, "/rest/getMusicDirectory.view", q2)
|
||||
defer resp2.Body.Close()
|
||||
var dirOut struct {
|
||||
Directory struct {
|
||||
Child []struct {
|
||||
IsDir bool `xml:"isDir,attr"`
|
||||
Title string `xml:"title,attr"`
|
||||
} `xml:"child"`
|
||||
} `xml:"directory"`
|
||||
}
|
||||
if err := xml.NewDecoder(resp2.Body).Decode(&dirOut); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(dirOut.Directory.Child) != 1 || !dirOut.Directory.Child[0].IsDir {
|
||||
t.Fatalf("expected one subdirectory (the album), got %+v", dirOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMusicDirectoryUnauthorizedFolder(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
u := createTestUser(t, s, "alice", "password123", false)
|
||||
folder := seedLibrary(t, s)
|
||||
srv := httptest.NewServer(s.Router())
|
||||
defer srv.Close()
|
||||
|
||||
// Restrict alice to a different (nonexistent) folder set, i.e. no access to `folder`.
|
||||
other, err := database.CreateMusicFolder(db, "Other", t.TempDir(), "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.SetAllowedMusicFolders(db, u.ID, []string{other.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", encodeDirID(folder.ID, "Test Artist"))
|
||||
resp := getPath(t, srv.URL, "/rest/getMusicDirectory.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
|
||||
t.Fatalf("expected error 50 for restricted folder, got %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVideosEmpty(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv.URL, "/rest/getVideos.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
if j["status"] != "ok" {
|
||||
t.Fatalf("getVideos status = %v", j["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTopSongsUnknownArtist(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("artist", "Nonexistent Artist XYZ")
|
||||
resp := getPath(t, srv.URL, "/rest/getTopSongs.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "ok" {
|
||||
t.Fatalf("getTopSongs for unknown artist should return ok with empty list, got %+v", x)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user