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:
2026-08-23 00:15:00 -05:00
parent b6ec06f272
commit c7b472ab99
11 changed files with 792 additions and 1 deletions
+304
View File
@@ -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)
}
}