305 lines
8.6 KiB
Go
305 lines
8.6 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"context"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
|
)
|
|
|
|
// seedLibraryWithCoverArt is like seedLibrary but also drops a cover.jpg
|
|
// next to the album, so getCoverArt has something real to serve.
|
|
func seedLibraryWithCoverArt(t *testing.T, s *Server) *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, "Cover Artist", "Cover Album")
|
|
if err := os.MkdirAll(songDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 32, 32))
|
|
for y := 0; y < 32; y++ {
|
|
for x := 0; x < 32; x++ {
|
|
img.Set(x, y, color.RGBA{R: uint8(x * 8), G: uint8(y * 8), B: 100, A: 255})
|
|
}
|
|
}
|
|
coverFile, err := os.Create(filepath.Join(songDir, "cover.jpg"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := jpeg.Encode(coverFile, img, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
coverFile.Close()
|
|
|
|
out := filepath.Join(songDir, "Song.mp3")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "ffmpeg",
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
|
"-metadata", "title=Song",
|
|
"-metadata", "artist=Cover Artist",
|
|
"-metadata", "album=Cover Album",
|
|
"-codec:a", "libmp3lame", "-b:a", "64k",
|
|
out)
|
|
if combined, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("ffmpeg: %v\n%s", err, combined)
|
|
}
|
|
|
|
f, err := database.CreateMusicFolder(s.DB, "Cover 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 firstSong(t *testing.T, s *Server) *database.Song {
|
|
t.Helper()
|
|
songs, err := database.ListSongs(s.DB, 1, 0)
|
|
if err != nil || len(songs) == 0 {
|
|
t.Fatalf("ListSongs: %v %v", songs, err)
|
|
}
|
|
return songs[0]
|
|
}
|
|
|
|
func TestStreamDirect(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", song.ID)
|
|
resp := getPath(t, srv.URL, "/rest/stream.view", q)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("stream status = %d", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if len(body) == 0 {
|
|
t.Fatal("stream returned no bytes")
|
|
}
|
|
if resp.Header.Get("Accept-Ranges") != "bytes" {
|
|
t.Error("expected Accept-Ranges: bytes on direct stream")
|
|
}
|
|
}
|
|
|
|
func TestStreamRangeRequest(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", song.ID)
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/rest/stream.view?"+q.Encode(), nil)
|
|
req.Header.Set("Range", "bytes=0-99")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("status = %d, want 206", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestStreamTranscoded(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", song.ID)
|
|
q.Set("format", "opus")
|
|
q.Set("maxBitRate", "32")
|
|
resp := getPath(t, srv.URL, "/rest/stream.view", q)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("transcoded stream status = %d", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "audio/ogg" {
|
|
t.Errorf("Content-Type = %q, want audio/ogg", ct)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if len(body) == 0 {
|
|
t.Fatal("transcoded stream returned no bytes")
|
|
}
|
|
}
|
|
|
|
func TestStreamRequiresPermission(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
srv := httptest.NewServer(s.Router())
|
|
defer srv.Close()
|
|
|
|
if err := database.SetUserPermissions(db, u.ID, database.Permissions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", song.ID)
|
|
resp := getPath(t, srv.URL, "/rest/stream.view", q)
|
|
x := decodeXML(t, resp)
|
|
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
|
|
t.Fatalf("expected error 50 without stream permission, got %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestDownloadRequiresPermission(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
srv := httptest.NewServer(s.Router())
|
|
defer srv.Close()
|
|
|
|
perms := u.Permissions
|
|
perms.Download = false
|
|
if err := database.SetUserPermissions(db, u.ID, perms); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", song.ID)
|
|
resp := getPath(t, srv.URL, "/rest/download.view", q)
|
|
x := decodeXML(t, resp)
|
|
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
|
|
t.Fatalf("expected error 50 without download permission, got %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestDownloadSuccess(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", song.ID)
|
|
resp := getPath(t, srv.URL, "/rest/download.view", q)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("download status = %d", resp.StatusCode)
|
|
}
|
|
if cd := resp.Header.Get("Content-Disposition"); cd == "" {
|
|
t.Error("expected Content-Disposition header on download")
|
|
}
|
|
}
|
|
|
|
func TestStreamNotFound(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", "sg_doesnotexist")
|
|
resp := getPath(t, srv.URL, "/rest/stream.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 TestGetCoverArtFolderImage(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibraryWithCoverArt(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)
|
|
}
|
|
albums, err := database.ListAlbumsByArtist(db, artists[0].ID)
|
|
if err != nil || len(albums) == 0 || !albums[0].CoverArtID.Valid {
|
|
t.Fatalf("expected fixture album to have cover art: albums=%v err=%v", albums, err)
|
|
}
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", albums[0].CoverArtID.String)
|
|
resp := getPath(t, srv.URL, "/rest/getCoverArt.view", q)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("getCoverArt status = %d: %s", resp.StatusCode, body)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct == "" {
|
|
t.Error("expected a Content-Type on cover art response")
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if len(body) == 0 {
|
|
t.Fatal("cover art response body is empty")
|
|
}
|
|
}
|
|
|
|
func TestGetLyricsNotFound(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("title", "Some Nonexistent Song")
|
|
q.Set("f", "json")
|
|
resp := getPath(t, srv.URL, "/rest/getLyrics.view", q)
|
|
j := decodeJSON(t, resp)
|
|
if j["status"] != "ok" {
|
|
t.Fatalf("getLyrics should succeed with empty result, got %v", j)
|
|
}
|
|
}
|
|
|
|
func TestGetAvatarNotFound(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("username", "alice")
|
|
resp := getPath(t, srv.URL, "/rest/getAvatar.view", q)
|
|
x := decodeXML(t, resp)
|
|
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
|
t.Fatalf("expected error 70 (no avatar feature), got %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestHLSPlaylist(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
q := baseParams("alice", "password123")
|
|
q.Set("id", song.ID)
|
|
resp := getPath(t, srv.URL, "/rest/hls.view", q)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("hls status = %d", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
text := string(body)
|
|
if len(text) == 0 || text[0:7] != "#EXTM3U" {
|
|
t.Fatalf("expected m3u8 playlist, got: %s", text)
|
|
}
|
|
}
|