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 0000000..7ae6f6a Binary files /dev/null and b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2496060147_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts differ 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 0000000..7ae6f6a Binary files /dev/null and b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist66102143_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts differ 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