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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -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
|
||||
BIN
Binary file not shown.
+7
@@ -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
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user