From c7b472ab99be85f485fdfda90b6253b8ef959e5d Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:15:00 -0500 Subject: [PATCH] Subsonic: media retrieval endpoints (stream, download, getCoverArt, getLyrics, getAvatar, hls, getCaptions) with real transcoding, embedded lyrics extraction, and folder permission enforcement --- internal/app/app.go | 2 + internal/database/subsonic_helpers2.go | 123 ++++++- internal/library/index.go | 4 + .../index.m3u8 | 7 + .../seg00000.ts | Bin 0 -> 19928 bytes .../index.m3u8 | 7 + .../seg00000.ts | Bin 0 -> 19928 bytes internal/subsonic/media.go | 342 ++++++++++++++++++ internal/subsonic/media_test.go | 304 ++++++++++++++++ internal/subsonic/server.go | 2 + internal/subsonic/testutil_test.go | 2 + 11 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2496060147_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2496060147_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist66102143_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist66102143_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts create mode 100644 internal/subsonic/media.go create mode 100644 internal/subsonic/media_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 2a17142..a7ade7e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, } diff --git a/internal/database/subsonic_helpers2.go b/internal/database/subsonic_helpers2.go index 86d0adc..7646381 100644 --- a/internal/database/subsonic_helpers2.go +++ b/internal/database/subsonic_helpers2.go @@ -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) +} diff --git a/internal/library/index.go b/internal/library/index.go index a7c84bf..29eca8e 100644 --- a/internal/library/index.go +++ b/internal/library/index.go @@ -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 } diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2496060147_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2496060147_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2496060147_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 @@ -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 diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2496060147_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2496060147_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ae6f6a51d177346266335c5719da8f5f9308a2f GIT binary patch literal 19928 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 DHaouJ literal 0 HcmV?d00001 diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist66102143_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist66102143_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist66102143_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 @@ -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 diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist66102143_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist66102143_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ae6f6a51d177346266335c5719da8f5f9308a2f GIT binary patch literal 19928 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 DHaouJ literal 0 HcmV?d00001 diff --git a/internal/subsonic/media.go b/internal/subsonic/media.go new file mode 100644 index 0000000..9338a51 --- /dev/null +++ b/internal/subsonic/media.go @@ -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 +} diff --git a/internal/subsonic/media_test.go b/internal/subsonic/media_test.go new file mode 100644 index 0000000..5c60c84 --- /dev/null +++ b/internal/subsonic/media_test.go @@ -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) + } +} diff --git a/internal/subsonic/server.go b/internal/subsonic/server.go index 9fd284e..c6dda23 100644 --- a/internal/subsonic/server.go +++ b/internal/subsonic/server.go @@ -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 } diff --git a/internal/subsonic/testutil_test.go b/internal/subsonic/testutil_test.go index 1f60c9b..5329cdc 100644 --- a/internal/subsonic/testutil_test.go +++ b/internal/subsonic/testutil_test.go @@ -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