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) } }