Subsonic: media retrieval endpoints (stream, download, getCoverArt, getLyrics, getAvatar, hls, getCaptions) with real transcoding, embedded lyrics extraction, and folder permission enforcement
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user