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
+2
View File
@@ -19,6 +19,7 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
"git.noroutes.net/NoRoutes/vibetunes/internal/subsonic"
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
"git.noroutes.net/NoRoutes/vibetunes/internal/web"
)
@@ -87,6 +88,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
EncryptionKey: encryptionKey,
Scanner: sc,
CoverArt: covers,
Transcoder: transcoding.NewManager(cfg.FFmpegPath),
Log: log,
}
+122 -1
View File
@@ -1,6 +1,10 @@
package database
import "database/sql"
import (
"database/sql"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
// ListVideoMediaFiles returns every media file the scanner identified as
// a video (as opposed to audio) track.
@@ -138,3 +142,120 @@ func AlbumsByRecentlyPlayed(db *sql.DB, userID string, limit, offset int) ([]*Al
defer rows.Close()
return scanAlbums(rows)
}
// SetSongLyrics replaces a song's stored (unsynced) lyrics with
// plainText, split into lines. Called by the scanner when a file has
// embedded lyrics.
func SetSongLyrics(db *sql.DB, songID, language, plainText string) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM lyrics WHERE song_id = ?`, songID); err != nil {
return err
}
if plainText == "" {
return tx.Commit()
}
lyricsID := idgen.New("ly")
if _, err := tx.Exec(`INSERT INTO lyrics (id, song_id, language, source, synced) VALUES (?, ?, ?, 'embedded', 0)`,
lyricsID, songID, language); err != nil {
return err
}
for i, line := range splitLines(plainText) {
if _, err := tx.Exec(`INSERT INTO lyrics_lines (id, lyrics_id, line_index, text) VALUES (?, ?, ?, ?)`,
idgen.New("ll"), lyricsID, i, line); err != nil {
return err
}
}
return tx.Commit()
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
line := s[start:i]
line = trimCR(line)
lines = append(lines, line)
start = i + 1
}
}
lines = append(lines, trimCR(s[start:]))
return lines
}
func trimCR(s string) string {
if len(s) > 0 && s[len(s)-1] == '\r' {
return s[:len(s)-1]
}
return s
}
// SongLyricsPlainText returns a song's stored lyrics as plain text
// (lines joined with "\n"), and whether any were found.
func SongLyricsPlainText(db *sql.DB, songID string) (string, bool, error) {
var lyricsID string
err := db.QueryRow(`SELECT id FROM lyrics WHERE song_id = ? ORDER BY created_at DESC LIMIT 1`, songID).Scan(&lyricsID)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, err
}
rows, err := db.Query(`SELECT text FROM lyrics_lines WHERE lyrics_id = ? ORDER BY line_index`, lyricsID)
if err != nil {
return "", false, err
}
defer rows.Close()
var lines []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return "", false, err
}
lines = append(lines, t)
}
if err := rows.Err(); err != nil {
return "", false, err
}
text := ""
for i, l := range lines {
if i > 0 {
text += "\n"
}
text += l
}
return text, true, nil
}
// FindSongByTitleArtist searches for a song by title (and optionally
// artist name), used by the legacy getLyrics endpoint.
func FindSongByTitleArtist(db *sql.DB, title, artist string) (*Song, error) {
if artist == "" {
var id string
err := db.QueryRow(`SELECT id FROM songs WHERE title = ? COLLATE NOCASE LIMIT 1`, title).Scan(&id)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return GetSong(db, id)
}
var id string
err := db.QueryRow(`SELECT s.id FROM songs s
JOIN song_artists sa ON sa.song_id = s.id
JOIN artists a ON a.id = sa.artist_id
WHERE s.title = ? COLLATE NOCASE AND a.name = ? COLLATE NOCASE LIMIT 1`, title, artist).Scan(&id)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return GetSong(db, id)
}
+4
View File
@@ -114,6 +114,10 @@ func IndexFile(db *sql.DB, mediaFile *database.MediaFile, tags *metadata.Tags) (
}
}
if err := database.SetSongLyrics(db, song.ID, "xxx", tags.EmbeddedLyrics); err != nil {
return nil, err
}
return song, nil
}
@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:1
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:1.023322,
seg00000.ts
#EXT-X-ENDLIST
@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:1
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:1.023322,
seg00000.ts
#EXT-X-ENDLIST
+342
View File
@@ -0,0 +1,342 @@
package subsonic
import (
"context"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
)
func init() {
register("stream", handleStream)
register("download", handleDownload)
register("getCoverArt", handleGetCoverArt)
register("getLyrics", handleGetLyrics)
register("getAvatar", handleGetAvatar)
register("hls", handleHLS)
register("getCaptions", handleGetCaptions)
}
// resolveSongFile loads a song's media file and folder, checking the
// requesting user's folder permission. Every media-retrieval endpoint
// goes through this so the authorization check can never be forgotten
// in one of them.
func resolveSongFile(rc *Context, songID string) (mf *database.MediaFile, folder *database.MusicFolder, aerr *apiError) {
db := rc.Server.DB
song, err := database.GetSong(db, songID)
if err != nil {
return nil, nil, errNotFound("Song")
}
mf, err = database.GetMediaFile(db, song.MediaFileID)
if err != nil {
return nil, nil, errNotFound("Song")
}
if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil {
return nil, nil, newError(ErrGeneric, cerr.Error())
} else if !canSee {
return nil, nil, errUnauthorized("this song")
}
folder, err = database.GetMusicFolder(db, mf.MusicFolderID)
if err != nil {
return nil, nil, newError(ErrGeneric, err.Error())
}
return mf, folder, nil
}
func handleStream(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Stream) {
return errUnauthorized("streaming")
}
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
mf, folder, aerr := resolveSongFile(rc, id)
if aerr != nil {
return aerr
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
formatParam := rc.Params.Get("format")
maxBitRate := rc.Params.Int("maxBitRate", 0)
timeOffset := rc.Params.Int("timeOffset", 0)
rc.Handled = true
// format=raw explicitly forces the original bytes. Otherwise,
// transcode only if the client actually asked for something the
// source doesn't already satisfy: a specific format, or a bitrate
// cap below the source's own bitrate.
needsTranscode := formatParam != "" && formatParam != "raw"
if maxBitRate > 0 && (mf.BitrateKbps == 0 || maxBitRate < mf.BitrateKbps) {
needsTranscode = true
}
if !needsTranscode {
if err := streaming.ServeFile(rc.W, rc.R, absPath, mf.RelativePath); err != nil {
http.Error(rc.W, "could not read media file", http.StatusInternalServerError)
}
return nil
}
format := transcoding.ParseFormat(formatParam)
rc.W.Header().Set("Content-Type", format.MimeType())
ctx := rc.R.Context()
if err := rc.Server.Transcoder.Stream(ctx, rc.W, absPath, transcoding.Options{
Format: format, MaxBitRateKbps: maxBitRate, TimeOffset: float64(timeOffset),
}); err != nil && ctx.Err() == nil {
rc.Server.Log.Warn("transcode failed", "err", err, "path", absPath)
}
return nil
}
func handleDownload(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Download) {
return errUnauthorized("downloading")
}
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
mf, folder, aerr := resolveSongFile(rc, id)
if aerr != nil {
return aerr
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
rc.Handled = true
filename := filepath.Base(mf.RelativePath)
rc.W.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
if err := streaming.ServeFile(rc.W, rc.R, absPath, filename); err != nil {
http.Error(rc.W, "could not read media file", http.StatusInternalServerError)
}
return nil
}
func handleGetCoverArt(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.CoverArt) {
return errUnauthorized("cover art")
}
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
size := rc.Params.Int("size", 0)
db := rc.Server.DB
art, err := database.GetCoverArt(db, id)
if err != nil {
return errNotFound("Cover art")
}
var sourcePath, contentHash string
var isTemp bool
if art.Source == "folder" {
sourcePath, contentHash = art.SourcePath, art.ContentHash
} else {
mf, merr := database.GetMediaFile(db, art.MediaFileID.String)
if merr != nil {
return errNotFound("Cover art")
}
folder, ferr := database.GetMusicFolder(db, mf.MusicFolderID)
if ferr != nil {
return newError(ErrGeneric, ferr.Error())
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
prober := &metadata.Prober{FFprobePath: rc.Server.Config.FFprobePath}
ctx, cancel := contextWithTimeout(rc.R.Context())
defer cancel()
tags, terr := prober.Probe(ctx, absPath)
if terr != nil || !tags.HasEmbeddedArt {
return errNotFound("Cover art")
}
extracted, eerr := rc.Server.CoverArt.ExtractEmbedded(ctx, absPath, tags.EmbeddedArtStream)
if eerr != nil {
return newError(ErrGeneric, eerr.Error())
}
sourcePath, contentHash, isTemp = extracted, art.ContentHash, true
}
if isTemp {
defer os.Remove(sourcePath)
}
cachedPath, gerr := rc.Server.CoverArt.GetOrCreate(sourcePath, contentHash, size)
if gerr != nil {
return newError(ErrGeneric, gerr.Error())
}
rc.Handled = true
rc.W.Header().Set("Cache-Control", "public, max-age=604800, immutable")
if err := streaming.ServeFile(rc.W, rc.R, cachedPath, "cover.jpg"); err != nil {
http.Error(rc.W, "could not read cover art", http.StatusInternalServerError)
}
return nil
}
func contextWithTimeout(parent context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, 15*time.Second)
}
func handleGetLyrics(rc *Context) *apiError {
title := rc.Params.Get("title")
artist := rc.Params.Get("artist")
out := &Lyrics{Artist: artist, Title: title}
if title != "" {
db := rc.Server.DB
if song, err := database.FindSongByTitleArtist(db, title, artist); err == nil {
if text, found, _ := database.SongLyricsPlainText(db, song.ID); found {
out.Value = text
}
}
}
rc.Response = newResponse()
rc.Response.Lyrics = out
return nil
}
// handleGetAvatar: VibeSonic has no user-avatar feature (Phase 1/2
// scope), so honestly reports "not found" rather than fabricating an
// image.
func handleGetAvatar(rc *Context) *apiError {
if _, aerr := rc.Params.Required("username"); aerr != nil {
return aerr
}
return errNotFound("Avatar")
}
func handleHLS(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Stream) {
return errUnauthorized("streaming")
}
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
mf, folder, aerr := resolveSongFile(rc, id)
if aerr != nil {
return aerr
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
bitRate := rc.Params.Int("bitRate", 0)
opts := transcoding.Options{Format: transcoding.FormatAAC, MaxBitRateKbps: bitRate}
if segStr := rc.Params.Get("segment"); segStr != "" {
segIndex, serr := strconv.Atoi(segStr)
if serr != nil {
return newError(ErrGeneric, "invalid segment")
}
session, herr := rc.Server.Transcoder.BuildHLS(rc.R.Context(), absPath, rc.Server.hlsCacheDir(), opts)
if herr != nil {
return newError(ErrGeneric, herr.Error())
}
path := session.SegmentPath(segIndex)
if path == "" {
return errNotFound("HLS segment")
}
rc.Handled = true
rc.W.Header().Set("Content-Type", "video/mp2t")
if err := streaming.ServeFile(rc.W, rc.R, path, "segment.ts"); err != nil {
http.Error(rc.W, "could not read segment", http.StatusInternalServerError)
}
return nil
}
session, herr := rc.Server.Transcoder.BuildHLS(rc.R.Context(), absPath, rc.Server.hlsCacheDir(), opts)
if herr != nil {
return newError(ErrGeneric, herr.Error())
}
authQuery := authQueryString(rc)
playlist := session.Playlist(func(i int) string {
return "hls.view?id=" + url.QueryEscape(id) + "&segment=" + strconv.Itoa(i) +
bitRateQuerySuffix(bitRate) + "&" + authQuery
})
rc.Handled = true
rc.W.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
_, _ = rc.W.Write([]byte(playlist))
return nil
}
func bitRateQuerySuffix(bitRate int) string {
if bitRate <= 0 {
return ""
}
return "&bitRate=" + strconv.Itoa(bitRate)
}
// authQueryString re-embeds the caller's own authentication parameters
// into generated segment URLs, so a bare HLS player (which won't know
// Subsonic's auth scheme) can fetch subsequent segments the same way it
// fetched the playlist.
func authQueryString(rc *Context) string {
var parts []string
for _, name := range []string{"u", "p", "t", "s", "v", "c"} {
if v := rc.Params.Get(name); v != "" {
parts = append(parts, name+"="+url.QueryEscape(v))
}
}
return strings.Join(parts, "&")
}
func (s *Server) hlsCacheDir() string {
return filepath.Join(s.Config.DataDir, "cache", "hls")
}
func handleGetCaptions(rc *Context) *apiError {
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
parts := strings.SplitN(id, ":cap:", 2)
if len(parts) != 2 {
return errNotFound("Captions")
}
songID, indexStr := parts[0], parts[1]
index, ierr := strconv.Atoi(indexStr)
if ierr != nil {
return errNotFound("Captions")
}
mf, folder, aerr := resolveSongFile(rc, songID)
if aerr != nil {
return aerr
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
captions := findSidecarCaptions(absPath)
if index < 0 || index >= len(captions) {
return errNotFound("Captions")
}
rc.Handled = true
if err := streaming.ServeFile(rc.W, rc.R, captions[index], filepath.Base(captions[index])); err != nil {
http.Error(rc.W, "could not read captions", http.StatusInternalServerError)
}
return nil
}
+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)
}
}
+2
View File
@@ -9,6 +9,7 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
)
// Server holds every dependency the Subsonic REST API needs. It mirrors
@@ -21,6 +22,7 @@ type Server struct {
EncryptionKey []byte
Scanner *scanner.Scanner
CoverArt *coverart.Store
Transcoder *transcoding.Manager
Log *slog.Logger
}
+2
View File
@@ -14,6 +14,7 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
)
func newTestServer(t *testing.T) (*Server, *sql.DB) {
@@ -36,6 +37,7 @@ func newTestServer(t *testing.T) (*Server, *sql.DB) {
EncryptionKey: key,
Scanner: scanner.New(db, &metadata.Prober{}, log),
CoverArt: coverart.New(t.TempDir(), ""),
Transcoder: transcoding.NewManager(""),
Log: log,
}
return s, db