Compare commits

..

2 Commits

Author SHA1 Message Date
Deluan
29f98b889b chore(deps): update dependencies in go.mod and go.sum to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 13:23:58 -05:00
Kendall Garner
1e37e680d7 feat(agents): Add artist url and top and similar songs to ListenBrainz agent (#4934)
* feat(agents): Add artist url and top songs to ListenBrainz agent

* add newline at end of file

* respond to some feedback

* add more tests, include more metadata in top songs

* add duration to album info

* add similar artists from labs

* add similar artists and track radio

* fix(client): replace sort with slices.SortFunc for deterministic ordering of recordings with same score

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: typos

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: use struct literal initialization consistently

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: configurable artist and track algorithms

Signed-off-by: Deluan <deluan@navidrome.org>

* test configuration changes

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 13:20:42 -05:00
36 changed files with 1130 additions and 2258 deletions

View File

@@ -64,7 +64,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
Codec: props.Codec,
}
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)

View File

@@ -118,12 +118,129 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo
return err == nil && sk != ""
}
func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
if mbid == "" {
return "", agents.ErrNotFound
}
url, err := l.client.getArtistUrl(ctx, mbid)
if err != nil {
return "", err
}
return url, nil
}
func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, len(resp))
for i, t := range resp {
mbid := ""
if len(t.ArtistMBIDs) > 0 {
mbid = t.ArtistMBIDs[0]
}
res[i] = agents.Song{
Album: t.ReleaseName,
AlbumMBID: t.ReleaseMBID,
Artist: t.ArtistName,
ArtistMBID: mbid,
Duration: t.DurationMs,
Name: t.RecordingName,
MBID: t.RecordingMbid,
}
}
return res, nil
}
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
artists := make([]agents.Artist, len(resp))
for i, artist := range resp {
artists[i] = agents.Artist{
MBID: artist.MBID,
Name: artist.Name,
}
}
return artists, nil
}
func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
songs := make([]agents.Song, len(resp))
for i, song := range resp {
songs[i] = agents.Song{
Album: song.ReleaseName,
AlbumMBID: song.ReleaseMBID,
Artist: song.Artist,
MBID: song.MBID,
Name: song.Name,
}
}
return songs, nil
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return listenBrainzConstructor(ds)
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
}
})
}
var (
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
)

View File

@@ -4,11 +4,14 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@@ -162,4 +165,279 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
})
Describe("GetArtistUrl", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns artist url when MBID present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns error when url not present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when fetch calls fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when ListenBrainz returns an error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
})
Describe("GetTopSongs", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
})
It("returns all tracks when asked", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
{
ID: "",
Name: "String Theocracy",
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "String Theocracy",
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
Duration: 174000,
},
}))
})
It("returns only one track when prompted", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
}))
})
})
Describe("GetSimilarArtists", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
}))
})
})
Describe("GetSimilarTracks", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
{
ID: "",
Name: "Wake Me Up Before You GoGo",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artist: "Wham!",
ArtistMBID: "",
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
}))
})
})
})

View File

@@ -2,16 +2,29 @@ package listenbrainz
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
const (
lbzApiUrl = "https://api.listenbrainz.org/1/"
labsBase = "https://labs.api.listenbrainz.org/"
)
var (
ErrorNotFound = errors.New("listenbrainz: not found")
)
type listenBrainzError struct {
Code int
Message string
@@ -88,7 +101,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain
r := &listenBrainzRequest{
ApiKey: apiKey,
}
response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
if err != nil {
return nil, err
}
@@ -104,7 +117,7 @@ func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenI
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
@@ -122,7 +135,7 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
@@ -141,7 +154,7 @@ func (c *client) path(endpoint string) (string, error) {
return u.String(), nil
}
func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
b, _ := json.Marshal(r.Body)
uri, err := c.path(endpoint)
if err != nil {
@@ -177,3 +190,189 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string
return &response, nil
}
type lbzHttpError struct {
Code int `json:"code"`
Error string `json:"error"`
}
func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = params.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
// On a 200 code, there is no code. Decode using using error message if it exists
if resp.StatusCode != 200 {
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var lbzError lbzHttpError
jsonErr := decoder.Decode(&lbzError)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
}
return resp, err
}
type artistMetadataResult struct {
Rels struct {
OfficialHomepage string `json:"official homepage,omitempty"`
} `json:"rels,omitzero"`
}
func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
params := url.Values{}
params.Add("artist_mbids", mbid)
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
if err != nil {
return "", err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []artistMetadataResult
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
return "", ErrorNotFound
}
return response[0].Rels.OfficialHomepage, nil
}
type trackInfo struct {
ArtistName string `json:"artist_name"`
ArtistMBIDs []string `json:"artist_mbids"`
DurationMs uint32 `json:"length"`
RecordingName string `json:"recording_name"`
RecordingMbid string `json:"recording_mbid"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
}
func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []trackInfo
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) > count {
return response[0:count], nil
}
return response, nil
}
type artist struct {
MBID string `json:"artist_mbid"`
Name string `json:"name"`
Score int `json:"score"`
}
func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var artists []artist
jsonErr := decoder.Decode(&artists)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(artists) > limit {
return artists[:limit], nil
}
return artists, nil
}
type recording struct {
MBID string `json:"recording_mbid"`
Name string `json:"recording_name"`
Artist string `json:"artist_credit_name"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
Score int `json:"score"`
}
func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var recordings []recording
jsonErr := decoder.Decode(&recordings)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
// For whatever reason, labs API isn't guaranteed to give results in the proper order
// and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
// generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
slices.SortFunc(recordings, func(a, b recording) int {
return cmp.Or(
cmp.Compare(b.Score, a.Score), // Sort by score descending
cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
)
})
recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
return a.MBID == b.MBID
})
if len(recordings) > limit {
return recordings[:limit], nil
}
return recordings, nil
}

View File

@@ -4,10 +4,13 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -117,4 +120,345 @@ var _ = Describe("client", func() {
})
})
})
Context("getArtistUrl", func() {
baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without meaningful body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 501,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns not found when the artist has no official homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err.Error()).To(Equal("listenbrainz: not found"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns data when the artist has a homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
Expect(err).ToNot(HaveOccurred())
Expect(url).To(Equal("http://projectmili.com/"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getArtistTopSongs", func() {
baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 50)
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without standard body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 500,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 1)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns all tracks when given the opportunity", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 174000,
RecordingName: "String Theocracy",
RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
ReleaseName: "String Theocracy",
ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns a subset of tracks when allowed", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getSimilarArtists", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
}
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarArtists(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
}))
})
It("fetches a different endpoint when algorithm changes", func() {
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
})
Context("getSimilarRecordings", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
}
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarRecordings(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You GoGo",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
It("properly sorts by score and truncates duplicates", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
// There are actually 5 items. The dedup should happen FIRST
resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
Name: "Everybody Wants to Rule the World",
Artist: "Tears for Fears",
ReleaseName: "Songs From the Big Chair",
ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
Score: 68,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You GoGo",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
{
MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
Name: "Tainted Love",
Artist: "Soft Cell",
ReleaseName: "Non-Stop Erotic Cabaret",
ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
Score: 61,
},
}))
})
It("uses a different algorithm when configured", func() {
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
})
})

View File

@@ -19,7 +19,6 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -103,8 +102,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
decider := transcode.NewDecider(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics, decider)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}

View File

@@ -194,8 +194,10 @@ type deezerOptions struct {
}
type listenBrainzOptions struct {
Enabled bool
BaseURL string
Enabled bool
BaseURL string
ArtistAlgorithm string
TrackAlgorithm string
}
type httpHeaderOptions struct {
@@ -656,7 +658,9 @@ func setViperDefaults() {
viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")

View File

@@ -74,6 +74,10 @@ const (
DefaultHttpClientTimeOut = 10 * time.Second
DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/"
DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b')

View File

@@ -1,689 +0,0 @@
package transcode
import (
"context"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const (
tokenTTL = 12 * time.Hour
defaultBitrate = 256 // kbps
)
// Decider is the core service interface for making transcoding decisions
type Decider interface {
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error)
CreateTranscodeParams(decision *Decision) (string, error)
ParseTranscodeParams(token string) (*Params, error)
}
// ClientInfo represents client playback capabilities.
// All bitrate values are in kilobits per second (kbps)
type ClientInfo struct {
Name string
Platform string
MaxAudioBitrate int
MaxTranscodingAudioBitrate int
DirectPlayProfiles []DirectPlayProfile
TranscodingProfiles []Profile
CodecProfiles []CodecProfile
}
// DirectPlayProfile describes a format the client can play directly
type DirectPlayProfile struct {
Containers []string
AudioCodecs []string
Protocols []string
MaxAudioChannels int
}
// Profile describes a transcoding target the client supports
type Profile struct {
Container string
AudioCodec string
Protocol string
MaxAudioChannels int
}
// CodecProfile describes codec-specific limitations
type CodecProfile struct {
Type string
Name string
Limitations []Limitation
}
// Limitation describes a specific codec limitation
type Limitation struct {
Name string
Comparison string
Values []string
Required bool
}
// Protocol values (OpenSubsonic spec enum)
const (
ProtocolHTTP = "http"
ProtocolHLS = "hls"
)
// Comparison operators (OpenSubsonic spec enum)
const (
ComparisonEquals = "Equals"
ComparisonNotEquals = "NotEquals"
ComparisonLessThanEqual = "LessThanEqual"
ComparisonGreaterThanEqual = "GreaterThanEqual"
)
// Limitation names (OpenSubsonic spec enum)
const (
LimitationAudioChannels = "audioChannels"
LimitationAudioBitrate = "audioBitrate"
LimitationAudioProfile = "audioProfile"
LimitationAudioSamplerate = "audioSamplerate"
LimitationAudioBitdepth = "audioBitdepth"
)
// Codec profile types (OpenSubsonic spec enum)
const (
CodecProfileTypeAudio = "AudioCodec"
)
// Decision represents the internal decision result.
// All bitrate values are in kilobits per second (kbps).
type Decision struct {
MediaID string
CanDirectPlay bool
CanTranscode bool
TranscodeReasons []string
ErrorReason string
TargetFormat string
TargetBitrate int
TargetChannels int
SourceStream StreamDetails
TranscodeStream *StreamDetails
}
// StreamDetails describes audio stream properties.
// Bitrate is in kilobits per second (kbps).
type StreamDetails struct {
Container string
Codec string
Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added.
Bitrate int
SampleRate int
BitDepth int
Channels int
Duration float32
Size int64
IsLossless bool
}
// Params contains the parameters extracted from a transcode token.
// TargetBitrate is in kilobits per second (kbps).
type Params struct {
MediaID string
DirectPlay bool
TargetFormat string
TargetBitrate int
TargetChannels int
}
func NewDecider(ds model.DataStore) Decider {
return &deciderService{
ds: ds,
}
}
type deciderService struct {
ds model.DataStore
}
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) {
decision := &Decision{
MediaID: mf.ID,
}
sourceBitrate := mf.BitRate // kbps
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", mf.Suffix,
"codec", mf.AudioCodec(), "bitrate", sourceBitrate, "channels", mf.Channels,
"sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name)
// Build source stream details
decision.SourceStream = StreamDetails{
Container: mf.Suffix,
Codec: mf.AudioCodec(),
Bitrate: sourceBitrate,
SampleRate: mf.SampleRate,
BitDepth: mf.BitDepth,
Channels: mf.Channels,
Duration: mf.Duration,
Size: mf.Size,
IsLossless: mf.IsLossless(),
}
// Check global bitrate constraint first.
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
"sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
// Skip direct play profiles entirely — global constraint fails
} else {
// Try direct play profiles, collecting reasons for each failure
for _, profile := range clientInfo.DirectPlayProfiles {
if reason := s.checkDirectPlayProfile(mf, sourceBitrate, &profile, clientInfo); reason == "" {
decision.CanDirectPlay = true
decision.TranscodeReasons = nil // Clear any previously collected reasons
break
} else {
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
}
}
}
// If direct play is possible, we're done
if decision.CanDirectPlay {
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", mf.Suffix, "codec", mf.AudioCodec())
return decision, nil
}
// Try transcoding profiles (in order of preference)
for _, profile := range clientInfo.TranscodingProfiles {
if ts := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil {
decision.CanTranscode = true
decision.TargetFormat = ts.Container
decision.TargetBitrate = ts.Bitrate
decision.TargetChannels = ts.Channels
decision.TranscodeStream = ts
break
}
}
if decision.CanTranscode {
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
}
// If neither direct play nor transcode is possible
if !decision.CanDirectPlay && !decision.CanTranscode {
decision.ErrorReason = "no compatible playback profile found"
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
"container", mf.Suffix, "codec", mf.AudioCodec(), "reasons", decision.TranscodeReasons)
}
return decision, nil
}
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
// or a typed reason string if it doesn't match.
func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
// Check protocol (only http for now)
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
return "protocol not supported"
}
// Check container
if len(profile.Containers) > 0 && !matchesContainer(mf.Suffix, profile.Containers) {
return "container not supported"
}
// Check codec
if len(profile.AudioCodecs) > 0 && !matchesCodec(mf.AudioCodec(), profile.AudioCodecs) {
return "audio codec not supported"
}
// Check channels
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
return "audio channels not supported"
}
// Check codec-specific limitations
for _, codecProfile := range clientInfo.CodecProfiles {
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) {
if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" {
return reason
}
}
}
return ""
}
// checkLimitations checks codec profile limitations against source media.
// Returns "" if all limitations pass, or a typed reason string for the first failure.
func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string {
for _, lim := range limitations {
var ok bool
var reason string
switch lim.Name {
case LimitationAudioChannels:
ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values)
reason = "audio channels not supported"
case LimitationAudioSamplerate:
ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values)
reason = "audio samplerate not supported"
case LimitationAudioBitrate:
ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values)
reason = "audio bitrate not supported"
case LimitationAudioBitdepth:
ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values)
reason = "audio bitdepth not supported"
case LimitationAudioProfile:
// TODO: populate source profile when MediaFile has audio profile info
ok = checkStringLimitation("", lim.Comparison, lim.Values)
reason = "audio profile not supported"
default:
continue
}
if !ok && lim.Required {
return reason
}
}
return ""
}
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
type adjustResult int
const (
adjustNone adjustResult = iota // Value already satisfies the limitation
adjustAdjusted // Value was changed to fit the limitation
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
)
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
// Returns nil if the profile cannot produce a valid output.
func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) *StreamDetails {
// Check protocol (only http for now)
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
return nil
}
targetFormat := strings.ToLower(profile.Container)
if targetFormat == "" {
targetFormat = strings.ToLower(profile.AudioCodec)
}
// Verify we have a transcoding config for this format
tc, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat)
if err != nil || tc == nil {
log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat)
return nil
}
targetIsLossless := isLosslessFormat(targetFormat)
// Reject lossy to lossless conversion
if !mf.IsLossless() && targetIsLossless {
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
return nil
}
ts := &StreamDetails{
Container: targetFormat,
Codec: strings.ToLower(profile.AudioCodec),
SampleRate: mf.SampleRate,
Channels: mf.Channels,
IsLossless: targetIsLossless,
}
if ts.Codec == "" {
ts.Codec = targetFormat
}
// Determine target bitrate (all in kbps)
if mf.IsLossless() {
if !targetIsLossless {
// Lossless to lossy: use client's max transcoding bitrate or default
if clientInfo.MaxTranscodingAudioBitrate > 0 {
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
} else {
ts.Bitrate = defaultBitrate
}
} else {
// Lossless to lossless: check if bitrate is under the global max
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
"targetFormat", targetFormat, "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
return nil
}
// No explicit bitrate for lossless target (leave 0)
}
} else {
// Lossy to lossy: preserve source bitrate
ts.Bitrate = sourceBitrate
}
// Apply maxAudioBitrate as final cap on transcoded stream (#5)
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
ts.Bitrate = clientInfo.MaxAudioBitrate
}
// Apply MaxAudioChannels from the transcoding profile
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
ts.Channels = profile.MaxAudioChannels
}
// Apply codec profile limitations to the TARGET codec (#4)
targetCodec := ts.Codec
for _, codecProfile := range clientInfo.CodecProfiles {
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
continue
}
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
continue
}
for _, lim := range codecProfile.Limitations {
result := applyLimitation(sourceBitrate, &lim, ts)
// For lossless codecs, adjusting bitrate is not valid
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
return nil
}
if result == adjustCannotFit {
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
"comparison", lim.Comparison, "values", lim.Values)
return nil
}
}
}
return ts
}
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
// Returns the adjustment result.
func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult {
switch lim.Name {
case LimitationAudioChannels:
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
case LimitationAudioBitrate:
current := ts.Bitrate
if current == 0 {
current = sourceBitrate
}
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
case LimitationAudioSamplerate:
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
case LimitationAudioBitdepth:
if ts.BitDepth > 0 {
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
}
case LimitationAudioProfile:
// TODO: implement when audio profile data is available
}
return adjustNone
}
// applyIntLimitation applies a limitation comparison to a value.
// If the value needs adjusting, calls the setter and returns the result.
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
if len(values) == 0 {
return adjustNone
}
switch comparison {
case ComparisonLessThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return adjustNone
}
if current <= limit {
return adjustNone
}
setter(limit)
return adjustAdjusted
case ComparisonGreaterThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return adjustNone
}
if current >= limit {
return adjustNone
}
// Cannot upscale
return adjustCannotFit
case ComparisonEquals:
// Check if current value matches any allowed value
for _, v := range values {
if limit, ok := parseInt(v); ok && current == limit {
return adjustNone
}
}
// Find the closest allowed value below current (don't upscale)
var closest int
found := false
for _, v := range values {
if limit, ok := parseInt(v); ok && limit < current {
if !found || limit > closest {
closest = limit
found = true
}
}
}
if found {
setter(closest)
return adjustAdjusted
}
return adjustCannotFit
case ComparisonNotEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && current == limit {
return adjustCannotFit
}
}
return adjustNone
}
return adjustNone
}
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
exp := time.Now().Add(tokenTTL)
claims := map[string]any{
"mid": decision.MediaID,
"dp": decision.CanDirectPlay,
}
if decision.CanTranscode && decision.TargetFormat != "" {
claims["fmt"] = decision.TargetFormat
claims["br"] = decision.TargetBitrate
if decision.TargetChannels > 0 {
claims["ch"] = decision.TargetChannels
}
}
return auth.CreateExpiringPublicToken(exp, claims)
}
func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) {
claims, err := auth.Validate(token)
if err != nil {
return nil, err
}
params := &Params{}
if mid, ok := claims["mid"].(string); ok {
params.MediaID = mid
}
if dp, ok := claims["dp"].(bool); ok {
params.DirectPlay = dp
}
if fmt, ok := claims["fmt"].(string); ok {
params.TargetFormat = fmt
}
if br, ok := claims["br"].(float64); ok {
params.TargetBitrate = int(br)
}
if ch, ok := claims["ch"].(float64); ok {
params.TargetChannels = int(ch)
}
return params, nil
}
func containsIgnoreCase(slice []string, s string) bool {
return slices.ContainsFunc(slice, func(item string) bool {
return strings.EqualFold(item, s)
})
}
// containerAliasGroups maps each container alias to a canonical group name.
var containerAliasGroups = func() map[string]string {
groups := [][]string{
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
{"mpeg", "mp3", "mp2"},
{"ogg", "oga"},
{"aif", "aiff"},
{"asf", "wma"},
{"mpc", "mpp"},
{"wv"},
}
m := make(map[string]string)
for _, g := range groups {
canonical := g[0]
for _, name := range g {
m[name] = canonical
}
}
return m
}()
// matchesWithAliases checks if a value matches any entry in candidates,
// consulting the alias map for equivalent names.
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
value = strings.ToLower(value)
canonical := aliases[value]
for _, c := range candidates {
c = strings.ToLower(c)
if c == value {
return true
}
if canonical != "" && aliases[c] == canonical {
return true
}
}
return false
}
// matchesContainer checks if a file suffix matches any of the container names,
// including common aliases.
func matchesContainer(suffix string, containers []string) bool {
return matchesWithAliases(suffix, containers, containerAliasGroups)
}
// codecAliasGroups maps each codec alias to a canonical group name.
// Codecs within the same group are considered equivalent.
var codecAliasGroups = func() map[string]string {
groups := [][]string{
{"aac", "adts"},
{"ac3", "ac-3"},
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
{"mpc7", "musepack7"},
{"mpc8", "musepack8"},
{"wma1", "wmav1"},
{"wma2", "wmav2"},
{"wmalossless", "wma9lossless"},
{"wmapro", "wma9pro"},
{"shn", "shorten"},
{"mp4als", "als"},
}
m := make(map[string]string)
for _, g := range groups {
for _, name := range g {
m[name] = g[0] // canonical = first entry
}
}
return m
}()
// matchesCodec checks if a codec matches any of the codec names,
// including common aliases.
func matchesCodec(codec string, codecs []string) bool {
return matchesWithAliases(codec, codecs, codecAliasGroups)
}
func checkIntLimitation(value int, comparison string, values []string) bool {
if len(values) == 0 {
return true
}
switch comparison {
case ComparisonLessThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return true
}
return value <= limit
case ComparisonGreaterThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return true
}
return value >= limit
case ComparisonEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && value == limit {
return true
}
}
return false
case ComparisonNotEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && value == limit {
return false
}
}
return true
}
return true
}
// checkStringLimitation checks a string value against a limitation.
// Only Equals and NotEquals comparisons are meaningful for strings.
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
func checkStringLimitation(value string, comparison string, values []string) bool {
switch comparison {
case ComparisonEquals:
for _, v := range values {
if strings.EqualFold(value, v) {
return true
}
}
return false
case ComparisonNotEquals:
for _, v := range values {
if strings.EqualFold(value, v) {
return false
}
}
return true
}
return true
}
func parseInt(s string) (int, bool) {
v, err := strconv.Atoi(s)
if err != nil || v < 0 {
return 0, false
}
return v, true
}
func isLosslessFormat(format string) bool {
switch strings.ToLower(format) {
case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd":
return true
}
return false
}

View File

@@ -1,17 +0,0 @@
package transcode
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTranscode(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Transcode Suite")
}

View File

@@ -1,657 +0,0 @@
package transcode
import (
"context"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Decider", func() {
var (
ds *tests.MockDataStore
svc Decider
ctx context.Context
)
BeforeEach(func() {
ctx = context.Background()
ds = &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedTranscoding: &tests.MockTranscodingRepo{},
}
auth.Init(ds)
svc = NewDecider(ds)
})
Describe("MakeDecision", func() {
Context("Direct Play", func() {
It("allows direct play when profile matches", func() {
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}, MaxAudioChannels: 2},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
Expect(decision.CanTranscode).To(BeFalse())
Expect(decision.TranscodeReasons).To(BeEmpty())
})
It("rejects direct play when container doesn't match", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3"}, Protocols: []string{"http"}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
})
It("rejects direct play when codec doesn't match", func() {
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported"))
})
It("rejects direct play when channels exceed limit", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{"http"}, MaxAudioChannels: 2},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported"))
})
It("handles container aliases (aac -> m4a)", func() {
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("handles container aliases (mp4 -> m4a)", func() {
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("handles codec aliases (adts -> aac)", func() {
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{"http"}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("allows when protocol list is empty (any protocol)", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("allows when both container and codec lists are empty (wildcard)", func() {
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{}, AudioCodecs: []string{}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
})
Context("MaxAudioBitrate constraint", func() {
It("revokes direct play when bitrate exceeds maxAudioBitrate", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2}
ci := &ClientInfo{
MaxAudioBitrate: 500, // kbps
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{"http"}},
},
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
})
})
Context("Transcoding", func() {
It("selects transcoding when direct play isn't possible", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256, // kbps
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3"}, Protocols: []string{"http"}},
},
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TargetBitrate).To(Equal(256)) // kbps
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
})
It("rejects lossy to lossless transcoding", func() {
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeFalse())
})
It("uses default bitrate when client doesn't specify", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "mp3", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) // 256 kbps
})
It("preserves lossy bitrate when under max", func() {
mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2}
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256, // kbps
TranscodingProfiles: []Profile{
{Container: "mp3", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps
})
It("rejects unsupported transcoding format", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "aac", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeFalse())
})
It("applies maxAudioBitrate as final cap on transcoded stream", func() {
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}
ci := &ClientInfo{
MaxAudioBitrate: 96, // kbps
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate
})
It("selects first valid transcoding profile in order", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3"}, Protocols: []string{"http"}},
},
TranscodingProfiles: []Profile{
{Container: "opus", AudioCodec: "opus", Protocol: "http"},
{Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("opus"))
})
})
Context("Lossless to lossless transcoding", func() {
It("allows lossless to lossless when samplerate needs downsampling", func() {
// MockTranscodingRepo doesn't support "flac" format, so this would fail to find a config.
// This test documents the behavior: lossless→lossless requires server transcoding config.
mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}
ci := &ClientInfo{
MaxAudioBitrate: 1000,
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{"http"}},
},
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
})
It("sets IsLossless=true on transcoded stream when target is lossless", func() {
// Simulate DSD→FLAC transcoding by using a mock that supports "flac"
mockTranscoding := &tests.MockTranscodingRepo{}
ds.MockedTranscoding = mockTranscoding
svc = NewDecider(ds)
// MockTranscodingRepo doesn't support flac, so this will skip lossless profile.
// Use mp3 which is supported as the fallback.
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy
})
})
Context("No compatible profile", func() {
It("returns error when nothing matches", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}
ci := &ClientInfo{}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeFalse())
Expect(decision.ErrorReason).To(Equal("no compatible playback profile found"))
})
})
Context("Codec limitations on direct play", func() {
It("rejects direct play when codec limitation fails (required)", func() {
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "mp3",
Limitations: []Limitation{
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
})
It("allows direct play when optional limitation fails", func() {
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "mp3",
Limitations: []Limitation{
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("handles Equals comparison with multiple values", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{"http"}},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "flac",
Limitations: []Limitation{
{Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("rejects when Equals comparison doesn't match any value", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{"http"}},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "flac",
Limitations: []Limitation{
{Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
})
It("rejects direct play when audioProfile limitation fails (required)", func() {
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "aac",
Limitations: []Limitation{
{Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true},
},
},
},
}
// Source profile is empty (not yet populated from scanner), so Equals("LC") fails
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported"))
})
It("allows direct play when audioProfile limitation is optional", func() {
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "aac",
Limitations: []Limitation{
{Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("rejects direct play due to samplerate limitation", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{"http"}},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "flac",
Limitations: []Limitation{
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported"))
})
})
Context("Codec limitations on transcoded output", func() {
It("applies bitrate limitation to transcoded stream", func() {
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100}
ci := &ClientInfo{
MaxAudioBitrate: 96, // force transcode
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "mp3",
Limitations: []Limitation{
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"96"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.Bitrate).To(Equal(96))
})
It("applies channel limitation to transcoded stream", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16}
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "mp3",
Limitations: []Limitation{
{Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.Channels).To(Equal(2))
})
It("applies samplerate limitation to transcoded stream", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "mp3",
Limitations: []Limitation{
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
})
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "mp3",
Limitations: []Limitation{
{Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, Values: []string{"96000"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeFalse())
})
})
Context("Typed transcode reasons from multiple profiles", func() {
It("collects reasons from each failed direct play profile", func() {
mf := &model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{"http"}},
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
{Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
},
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.TranscodeReasons).To(HaveLen(3))
Expect(decision.TranscodeReasons[0]).To(Equal("container not supported"))
Expect(decision.TranscodeReasons[1]).To(Equal("container not supported"))
Expect(decision.TranscodeReasons[2]).To(Equal("container not supported"))
})
})
Context("Source stream details", func() {
It("populates source stream correctly with kbps bitrate", func() {
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{"http"}},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci)
Expect(err).ToNot(HaveOccurred())
Expect(decision.SourceStream.Container).To(Equal("flac"))
Expect(decision.SourceStream.Codec).To(Equal("flac"))
Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps
Expect(decision.SourceStream.SampleRate).To(Equal(96000))
Expect(decision.SourceStream.BitDepth).To(Equal(24))
Expect(decision.SourceStream.Channels).To(Equal(2))
})
})
})
Describe("Token round-trip", func() {
It("creates and parses a direct play token", func() {
decision := &Decision{
MediaID: "media-123",
CanDirectPlay: true,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
Expect(token).ToNot(BeEmpty())
params, err := svc.ParseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.MediaID).To(Equal("media-123"))
Expect(params.DirectPlay).To(BeTrue())
Expect(params.TargetFormat).To(BeEmpty())
})
It("creates and parses a transcode token with kbps bitrate", func() {
decision := &Decision{
MediaID: "media-456",
CanDirectPlay: false,
CanTranscode: true,
TargetFormat: "mp3",
TargetBitrate: 256, // kbps
TargetChannels: 2,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
params, err := svc.ParseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.MediaID).To(Equal("media-456"))
Expect(params.DirectPlay).To(BeFalse())
Expect(params.TargetFormat).To(Equal("mp3"))
Expect(params.TargetBitrate).To(Equal(256)) // kbps
Expect(params.TargetChannels).To(Equal(2))
})
It("rejects an invalid token", func() {
_, err := svc.ParseTranscodeParams("invalid-token")
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
)
var Set = wire.NewSet(
@@ -21,7 +20,6 @@ var Set = wire.NewSet(
NewLibrary,
NewUser,
NewMaintenance,
transcode.NewDecider,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),

View File

@@ -1,11 +0,0 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL;
CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS media_file_codec;
ALTER TABLE media_file DROP COLUMN codec;
-- +goose StatementEnd

14
go.mod
View File

@@ -7,14 +7,14 @@ replace (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
// Fork to implement raw tags support
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798
)
require (
github.com/Masterminds/squirrel v1.5.4
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
github.com/andybalholm/cascadia v1.3.3
github.com/bmatcuk/doublestar/v4 v4.9.2
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -28,7 +28,7 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/extism/go-sdk v1.7.1
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3
@@ -49,8 +49,8 @@ require (
github.com/mattn/go-sqlite3 v1.14.33
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.27.5
github.com/onsi/gomega v1.39.0
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pressly/goose/v3 v3.26.0
@@ -98,7 +98,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -134,7 +134,7 @@ require (
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect

28
go.sum
View File

@@ -16,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57 h1:SXIwfjzTv0UzoUWpFREl8p3AxXVLmbcto1/ISih11a0=
github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk=
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@@ -77,8 +77,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
@@ -110,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -197,10 +197,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -301,8 +301,8 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=

View File

@@ -14,7 +14,6 @@ import (
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf"
confmime "github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
@@ -57,7 +56,6 @@ type MediaFile struct {
SampleRate int `structs:"sample_rate" json:"sampleRate"`
BitDepth int `structs:"bit_depth" json:"bitDepth"`
Channels int `structs:"channels" json:"channels"`
Codec string `structs:"codec" json:"codec"`
Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres,omitempty"`
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
@@ -163,79 +161,6 @@ func (mf MediaFile) AbsolutePath() string {
return filepath.Join(mf.LibraryPath, mf.Path)
}
// AudioCodec returns the audio codec for this file.
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
func (mf MediaFile) AudioCodec() string {
// If we have a stored codec from scanning, normalize and return it
if mf.Codec != "" {
return strings.ToLower(mf.Codec)
}
// Fallback: infer from Suffix + BitDepth
return mf.inferCodecFromSuffix()
}
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
func (mf MediaFile) inferCodecFromSuffix() string {
switch strings.ToLower(mf.Suffix) {
case "mp3", "mpga":
return "mp3"
case "mp2":
return "mp2"
case "ogg", "oga":
return "vorbis"
case "opus":
return "opus"
case "mpc":
return "mpc"
case "wma":
return "wma"
case "flac":
return "flac"
case "wav":
return "pcm"
case "aif", "aiff", "aifc":
return "pcm"
case "ape":
return "ape"
case "wv", "wvp":
return "wv"
case "tta":
return "tta"
case "tak":
return "tak"
case "shn":
return "shn"
case "dsf", "dff":
return "dsd"
case "m4a":
// AAC if BitDepth==0, ALAC if BitDepth>0
if mf.BitDepth > 0 {
return "alac"
}
return "aac"
case "m4b", "m4p", "m4r":
return "aac"
default:
return ""
}
}
// IsLossless returns true if this file uses a lossless codec.
func (mf MediaFile) IsLossless() bool {
codec := mf.AudioCodec()
// Primary: codec-based check (most accurate for containers like M4A)
switch codec {
case "flac", "alac", "pcm", "ape", "wv", "tta", "tak", "shn", "dsd":
return true
}
// Secondary: suffix-based check using configurable list from YAML
if slices.Contains(confmime.LosslessFormats, mf.Suffix) {
return true
}
// Fallback heuristic: if BitDepth is set, it's likely lossless
return mf.BitDepth > 0
}
type MediaFiles []MediaFile
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.

View File

@@ -475,7 +475,7 @@ var _ = Describe("MediaFile", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
Describe("CoverArtId", func() {
Describe(".CoverArtId()", func() {
It("returns its own id if it HasCoverArt", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID()
@@ -496,94 +496,6 @@ var _ = Describe("MediaFile", func() {
Expect(id.ID).To(Equal(mf.AlbumID))
})
})
Describe("AudioCodec", func() {
It("returns normalized stored codec when available", func() {
mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("aac"))
})
It("returns stored codec lowercased", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
DescribeTable("infers codec from suffix when Codec field is empty",
func(suffix string, bitDepth int, expected string) {
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
Expect(mf.AudioCodec()).To(Equal(expected))
},
Entry("mp3", "mp3", 0, "mp3"),
Entry("mpga", "mpga", 0, "mp3"),
Entry("mp2", "mp2", 0, "mp2"),
Entry("ogg", "ogg", 0, "vorbis"),
Entry("oga", "oga", 0, "vorbis"),
Entry("opus", "opus", 0, "opus"),
Entry("mpc", "mpc", 0, "mpc"),
Entry("wma", "wma", 0, "wma"),
Entry("flac", "flac", 0, "flac"),
Entry("wav", "wav", 0, "pcm"),
Entry("aif", "aif", 0, "pcm"),
Entry("aiff", "aiff", 0, "pcm"),
Entry("aifc", "aifc", 0, "pcm"),
Entry("ape", "ape", 0, "ape"),
Entry("wv", "wv", 0, "wv"),
Entry("wvp", "wvp", 0, "wv"),
Entry("tta", "tta", 0, "tta"),
Entry("tak", "tak", 0, "tak"),
Entry("shn", "shn", 0, "shn"),
Entry("dsf", "dsf", 0, "dsd"),
Entry("dff", "dff", 0, "dsd"),
Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
Entry("m4b", "m4b", 0, "aac"),
Entry("m4p", "m4p", 0, "aac"),
Entry("m4r", "m4r", 0, "aac"),
Entry("unknown suffix", "xyz", 0, ""),
)
It("prefers stored codec over suffix inference", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
})
Describe("IsLossless", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
DescribeTable("detects lossless codecs",
func(codec string, suffix string, bitDepth int, expected bool) {
mf := MediaFile{Codec: codec, Suffix: suffix, BitDepth: bitDepth}
Expect(mf.IsLossless()).To(Equal(expected))
},
Entry("flac", "FLAC", "flac", 16, true),
Entry("alac", "ALAC", "m4a", 24, true),
Entry("pcm via wav", "", "wav", 16, true),
Entry("pcm via aiff", "", "aiff", 24, true),
Entry("ape", "", "ape", 16, true),
Entry("wv", "", "wv", 0, true),
Entry("tta", "", "tta", 0, true),
Entry("tak", "", "tak", 0, true),
Entry("shn", "", "shn", 0, true),
Entry("dsd", "", "dsf", 0, true),
Entry("mp3 is lossy", "MP3", "mp3", 0, false),
Entry("aac is lossy", "AAC", "m4a", 0, false),
Entry("vorbis is lossy", "", "ogg", 0, false),
Entry("opus is lossy", "", "opus", 0, false),
)
It("detects lossless via BitDepth fallback when codec is unknown", func() {
mf := MediaFile{Suffix: "xyz", BitDepth: 24}
Expect(mf.IsLossless()).To(BeTrue())
})
It("returns false for unknown with no BitDepth", func() {
mf := MediaFile{Suffix: "xyz", BitDepth: 0}
Expect(mf.IsLossless()).To(BeFalse())
})
})
})
func t(v string) time.Time {

View File

@@ -65,7 +65,6 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth
mf.Channels = md.AudioProperties().Channels
mf.Codec = md.AudioProperties().Codec
mf.Path = md.FilePath()
mf.Suffix = md.Suffix()
mf.Size = md.Size()

View File

@@ -35,7 +35,6 @@ type AudioProperties struct {
BitDepth int
SampleRate int
Channels int
Codec string
}
type Date string

View File

@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
})

View File

@@ -16,7 +16,6 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
@@ -32,42 +31,40 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
type Router struct {
http.Handler
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
metrics metrics.Metrics
transcodeDecision transcode.Decider
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
metrics metrics.Metrics
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics, transcodeDecision transcode.Decider,
metrics metrics.Metrics,
) *Router {
r := &Router{
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
metrics: metrics,
transcodeDecision: transcodeDecision,
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
metrics: metrics,
}
r.Handler = r.routes()
return r
@@ -172,8 +169,6 @@ func (api *Router) routes() http.Handler {
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
hr(r, "stream", api.Stream)
hr(r, "download", api.Download)
hr(r, "getTranscodeDecision", api.GetTranscodeDecision)
hr(r, "getTranscodeStream", api.GetTranscodeStream)
})
r.Group(func(r chi.Router) {
// configure request throttling

View File

@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
ds = &tests.MockDataStore{}
playTracker = &fakePlayTracker{}
eventBroker = &fakeEventBroker{}
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
})
Describe("Scrobble", func() {

View File

@@ -33,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
MockedMediaFile: mockRepo,
}
artwork = &fakeArtwork{data: "image data"}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"

View File

@@ -13,7 +13,6 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
{Name: "indexBasedQueue", Versions: []int32{1}},
{Name: "transcoding", Versions: []int32{1}},
}
return response, nil
}

View File

@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
)
BeforeEach(func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
})
@@ -35,12 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(5),
HaveLen(4),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
))
})
})

View File

@@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
ctx = context.Background()
})
@@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
playlists = &fakePlaylists{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
})
It("clears the comment when parameter is empty", func() {

View File

@@ -61,7 +61,6 @@ type Subsonic struct {
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
}
const (
@@ -618,26 +617,3 @@ func marshalJSONArray[T any](v []T) ([]byte, error) {
}
return json.Marshal(v)
}
// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension)
type TranscodeDecision struct {
CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"`
CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"`
TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"`
ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"`
TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"`
SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"`
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
}
// StreamDetails describes audio stream properties for transcoding decisions
type StreamDetails struct {
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`
Container string `xml:"container,attr,omitempty" json:"container,omitempty"`
Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"`
AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"`
AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"`
AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"`
AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"`
AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"`
}

View File

@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Get references to the mock repositories so we can inspect their Options
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)

View File

@@ -1,349 +0,0 @@
package subsonic
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
)
// API-layer request structs for JSON unmarshaling (decoupled from core structs)
// clientInfoRequest represents client playback capabilities from the request body
type clientInfoRequest struct {
Name string `json:"name,omitempty"`
Platform string `json:"platform,omitempty"`
MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"`
MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"`
DirectPlayProfiles []directPlayProfileReq `json:"directPlayProfiles,omitempty"`
TranscodingProfiles []transcodingProfileReq `json:"transcodingProfiles,omitempty"`
CodecProfiles []codecProfileReq `json:"codecProfiles,omitempty"`
}
// directPlayProfileReq describes a format the client can play directly
type directPlayProfileReq struct {
Containers []string `json:"containers,omitempty"`
AudioCodecs []string `json:"audioCodecs,omitempty"`
Protocols []string `json:"protocols,omitempty"`
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
}
// transcodingProfileReq describes a transcoding target the client supports
type transcodingProfileReq struct {
Container string `json:"container,omitempty"`
AudioCodec string `json:"audioCodec,omitempty"`
Protocol string `json:"protocol,omitempty"`
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
}
// codecProfileReq describes codec-specific limitations
type codecProfileReq struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Limitations []limitationReq `json:"limitations,omitempty"`
}
// limitationReq describes a specific codec limitation
type limitationReq struct {
Name string `json:"name,omitempty"`
Comparison string `json:"comparison,omitempty"`
Values []string `json:"values,omitempty"`
Required bool `json:"required,omitempty"`
}
// toCoreClientInfo converts the API request struct to the transcode.ClientInfo struct.
// The OpenSubsonic spec uses bps for bitrate values; core uses kbps.
func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo {
ci := &transcode.ClientInfo{
Name: r.Name,
Platform: r.Platform,
MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate),
MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate),
}
for _, dp := range r.DirectPlayProfiles {
ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, transcode.DirectPlayProfile{
Containers: dp.Containers,
AudioCodecs: dp.AudioCodecs,
Protocols: dp.Protocols,
MaxAudioChannels: dp.MaxAudioChannels,
})
}
for _, tp := range r.TranscodingProfiles {
ci.TranscodingProfiles = append(ci.TranscodingProfiles, transcode.Profile{
Container: tp.Container,
AudioCodec: tp.AudioCodec,
Protocol: tp.Protocol,
MaxAudioChannels: tp.MaxAudioChannels,
})
}
for _, cp := range r.CodecProfiles {
coreCP := transcode.CodecProfile{
Type: cp.Type,
Name: cp.Name,
}
for _, lim := range cp.Limitations {
coreLim := transcode.Limitation{
Name: lim.Name,
Comparison: lim.Comparison,
Values: lim.Values,
Required: lim.Required,
}
// Convert audioBitrate limitation values from bps to kbps
if lim.Name == transcode.LimitationAudioBitrate {
coreLim.Values = convertBitrateValues(lim.Values)
}
coreCP.Limitations = append(coreCP.Limitations, coreLim)
}
ci.CodecProfiles = append(ci.CodecProfiles, coreCP)
}
return ci
}
// bpsToKbps converts bits per second to kilobits per second.
func bpsToKbps(bps int) int {
return bps / 1000
}
// kbpsToBps converts kilobits per second to bits per second.
func kbpsToBps(kbps int) int {
return kbps * 1000
}
// convertBitrateValues converts a slice of bps string values to kbps string values.
func convertBitrateValues(bpsValues []string) []string {
result := make([]string, len(bpsValues))
for i, v := range bpsValues {
n, err := strconv.Atoi(v)
if err == nil {
result[i] = strconv.Itoa(n / 1000)
} else {
result[i] = v // preserve unparseable values as-is
}
}
return result
}
// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec.
func (r *clientInfoRequest) validate() error {
for _, dp := range r.DirectPlayProfiles {
for _, p := range dp.Protocols {
if !isValidProtocol(p) {
return fmt.Errorf("invalid protocol: %s", p)
}
}
}
for _, tp := range r.TranscodingProfiles {
if tp.Protocol != "" && !isValidProtocol(tp.Protocol) {
return fmt.Errorf("invalid protocol: %s", tp.Protocol)
}
}
for _, cp := range r.CodecProfiles {
if !isValidCodecProfileType(cp.Type) {
return fmt.Errorf("invalid codec profile type: %s", cp.Type)
}
for _, lim := range cp.Limitations {
if !isValidLimitationName(lim.Name) {
return fmt.Errorf("invalid limitation name: %s", lim.Name)
}
if !isValidComparison(lim.Comparison) {
return fmt.Errorf("invalid comparison: %s", lim.Comparison)
}
}
}
return nil
}
func isValidProtocol(p string) bool {
return p == transcode.ProtocolHTTP || p == transcode.ProtocolHLS
}
func isValidCodecProfileType(t string) bool {
return t == transcode.CodecProfileTypeAudio
}
func isValidLimitationName(n string) bool {
return n == transcode.LimitationAudioChannels ||
n == transcode.LimitationAudioBitrate ||
n == transcode.LimitationAudioProfile ||
n == transcode.LimitationAudioSamplerate ||
n == transcode.LimitationAudioBitdepth
}
func isValidComparison(c string) bool {
return c == transcode.ComparisonEquals ||
c == transcode.ComparisonNotEquals ||
c == transcode.ComparisonLessThanEqual ||
c == transcode.ComparisonGreaterThanEqual
}
// GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint.
// It receives client capabilities and returns a decision on whether to direct play or transcode.
func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return nil, nil
}
ctx := r.Context()
p := req.Params(r)
mediaID, err := p.String("mediaId")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
}
mediaType, err := p.String("mediaType")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
}
// Only support songs for now
if mediaType != "song" {
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
}
// Parse and validate ClientInfo from request body (required per OpenSubsonic spec)
var clientInfoReq clientInfoRequest
if r.Body == nil {
return nil, newError(responses.ErrorMissingParameter, "missing required JSON request body")
}
if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil {
return nil, newError(responses.ErrorGeneric, "invalid JSON request body")
}
if err := clientInfoReq.validate(); err != nil {
return nil, newError(responses.ErrorGeneric, "%v", err)
}
clientInfo := clientInfoReq.toCoreClientInfo()
// Get media file
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
if err != nil {
return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID)
}
// Make the decision
decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo)
if err != nil {
return nil, newError(responses.ErrorGeneric, "failed to make transcode decision: %v", err)
}
// Create transcode params token
transcodeParams, err := api.transcodeDecision.CreateTranscodeParams(decision)
if err != nil {
return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err)
}
// Build response (convert kbps from core to bps for the API)
response := newResponse()
response.TranscodeDecision = &responses.TranscodeDecision{
CanDirectPlay: decision.CanDirectPlay,
CanTranscode: decision.CanTranscode,
TranscodeReasons: decision.TranscodeReasons,
ErrorReason: decision.ErrorReason,
TranscodeParams: transcodeParams,
SourceStream: &responses.StreamDetails{
Protocol: "http",
Container: decision.SourceStream.Container,
Codec: decision.SourceStream.Codec,
AudioBitrate: int32(kbpsToBps(decision.SourceStream.Bitrate)),
AudioProfile: decision.SourceStream.Profile,
AudioSamplerate: int32(decision.SourceStream.SampleRate),
AudioBitdepth: int32(decision.SourceStream.BitDepth),
AudioChannels: int32(decision.SourceStream.Channels),
},
}
if decision.TranscodeStream != nil {
response.TranscodeDecision.TranscodeStream = &responses.StreamDetails{
Protocol: "http",
Container: decision.TranscodeStream.Container,
Codec: decision.TranscodeStream.Codec,
AudioBitrate: int32(kbpsToBps(decision.TranscodeStream.Bitrate)),
AudioProfile: decision.TranscodeStream.Profile,
AudioSamplerate: int32(decision.TranscodeStream.SampleRate),
AudioBitdepth: int32(decision.TranscodeStream.BitDepth),
AudioChannels: int32(decision.TranscodeStream.Channels),
}
}
return response, nil
}
// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint.
// It streams media using the decision encoded in the transcodeParams JWT token.
func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
mediaID, err := p.String("mediaId")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
}
mediaType, err := p.String("mediaType")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
}
transcodeParams, err := p.String("transcodeParams")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: transcodeParams")
}
// Only support songs for now
if mediaType != "song" {
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
}
// Parse and validate the token
params, err := api.transcodeDecision.ParseTranscodeParams(transcodeParams)
if err != nil {
log.Warn(ctx, "Failed to parse transcode token", err)
return nil, newError(responses.ErrorDataNotFound, "invalid or expired transcodeParams token")
}
// Verify mediaId matches token
if params.MediaID != mediaID {
return nil, newError(responses.ErrorDataNotFound, "mediaId does not match token")
}
// Determine streaming parameters
format := ""
maxBitRate := 0
if !params.DirectPlay && params.TargetFormat != "" {
format = params.TargetFormat
maxBitRate = params.TargetBitrate // Already in kbps, matching the streamer
}
// Get offset parameter
offset := p.IntOr("offset", 0)
// Create stream
stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, offset)
if err != nil {
return nil, err
}
// Make sure the stream will be closed at the end
defer func() {
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error("Error closing stream", "id", mediaID, "file", stream.Name(), err)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
api.serveStream(ctx, w, r, stream, mediaID)
return nil, nil
}

View File

@@ -1,268 +0,0 @@
package subsonic
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Transcode endpoints", func() {
var (
router *Router
ds *tests.MockDataStore
mockTD *mockTranscodeDecision
w *httptest.ResponseRecorder
mockMFRepo *tests.MockMediaFileRepo
)
BeforeEach(func() {
mockMFRepo = &tests.MockMediaFileRepo{}
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
mockTD = &mockTranscodeDecision{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
w = httptest.NewRecorder()
})
Describe("GetTranscodeDecision", func() {
It("returns 405 for non-POST requests", func() {
r := newGetRequest("mediaId=123", "mediaType=song")
resp, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
Expect(w.Header().Get("Allow")).To(Equal("POST"))
})
It("returns error when mediaId is missing", func() {
r := newJSONPostRequest("mediaType=song", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error when mediaType is missing", func() {
r := newJSONPostRequest("mediaId=123", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error for unsupported mediaType", func() {
r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not yet supported"))
})
It("returns error when media file not found", func() {
mockMFRepo.SetError(true)
r := newJSONPostRequest("mediaId=notfound&mediaType=song", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error when body is empty", func() {
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error when body contains invalid JSON", func() {
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error for invalid protocol in direct play profile", func() {
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
})
It("returns error for invalid comparison operator", func() {
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
})
It("returns error for invalid limitation name", func() {
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
})
It("returns error for invalid codec profile type", func() {
body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
})
It("rejects wrong-case protocol", func() {
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
})
It("rejects wrong-case codec profile type", func() {
body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
})
It("rejects wrong-case comparison operator", func() {
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
})
It("rejects wrong-case limitation name", func() {
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
})
It("returns a valid decision response", func() {
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100},
})
mockTD.decision = &transcode.Decision{
MediaID: "song-1",
CanDirectPlay: true,
SourceStream: transcode.StreamDetails{
Container: "mp3", Codec: "mp3", Bitrate: 320,
SampleRate: 44100, Channels: 2,
},
}
mockTD.token = "test-jwt-token"
body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
resp, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token"))
Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http"))
Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3"))
Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000)))
})
It("includes transcode stream when transcoding", func() {
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24},
})
mockTD.decision = &transcode.Decision{
MediaID: "song-2",
CanDirectPlay: false,
CanTranscode: true,
TargetFormat: "mp3",
TargetBitrate: 256,
TranscodeReasons: []string{"container not supported"},
SourceStream: transcode.StreamDetails{
Container: "flac", Codec: "flac", Bitrate: 1000,
SampleRate: 96000, BitDepth: 24, Channels: 2,
},
TranscodeStream: &transcode.StreamDetails{
Container: "mp3", Codec: "mp3", Bitrate: 256,
SampleRate: 96000, Channels: 2,
},
}
mockTD.token = "transcode-token"
r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}")
resp, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported"))
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
})
})
Describe("GetTranscodeStream", func() {
It("returns error when mediaId is missing", func() {
r := newGetRequest("mediaType=song", "transcodeParams=abc")
_, err := router.GetTranscodeStream(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error when transcodeParams is missing", func() {
r := newGetRequest("mediaId=123", "mediaType=song")
_, err := router.GetTranscodeStream(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error for invalid token", func() {
mockTD.parseErr = model.ErrNotFound
r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token")
_, err := router.GetTranscodeStream(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error when mediaId doesn't match token", func() {
mockTD.params = &transcode.Params{MediaID: "other-id", DirectPlay: true}
r := newGetRequest("mediaId=wrong-id", "mediaType=song", "transcodeParams=valid-token")
_, err := router.GetTranscodeStream(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("does not match"))
})
})
})
// newJSONPostRequest creates an HTTP POST request with JSON body and query params
func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody))
r.Header.Set("Content-Type", "application/json")
return r
}
// mockTranscodeDecision is a test double for core.TranscodeDecision
type mockTranscodeDecision struct {
decision *transcode.Decision
token string
tokenErr error
params *transcode.Params
parseErr error
}
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo) (*transcode.Decision, error) {
if m.decision != nil {
return m.decision, nil
}
return &transcode.Decision{}, nil
}
func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) {
return m.token, m.tokenErr
}
func (m *mockTranscodeDecision) ParseTranscodeParams(_ string) (*transcode.Params, error) {
if m.parseErr != nil {
return nil, m.parseErr
}
return m.params, nil
}

View File

@@ -0,0 +1,19 @@
[
{
"area": "Japan",
"artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
"begin_year": 2012,
"mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
"name": "Mili",
"rels": {
"free streaming": "https://www.deezer.com/artist/56563392",
"official homepage": "http://projectmili.com/",
"purchase for download": "https://recochoku.jp/artist/2000285803/",
"social network": "https://www.instagram.com/projectmili/",
"streaming": "https://tidal.com/artist/3848902",
"wikidata": "https://www.wikidata.org/wiki/Q27309228",
"youtube": "https://www.youtube.com/channel/UCVh47EKH9VLresRqiYi9txw"
},
"type": "Group"
}
]

View File

@@ -0,0 +1,15 @@
[
{
"area": "Japan",
"artist_mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973",
"mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973",
"name": "Feryquitous",
"rels": {
"free streaming": "https://www.deezer.com/artist/9841008",
"purchase for download": "https://itunes.apple.com/jp/artist/id1083544578",
"social network": "https://twitter.com/Feryquitous_",
"youtube": "https://www.youtube.com/channel/UCj2nw_9puY3sJoDbkE-FCQA"
},
"type": "Person"
}
]

View File

@@ -0,0 +1 @@
[{"artist_mbid": "f27ec8db-af05-4f36-916e-3d57f91ecf5e", "name": "Michael Jackson", "comment": "\u201cKing of Pop\u201d", "type": "Person", "gender": "Male", "score": 800, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}, {"artist_mbid": "7364dea6-ca9a-48e3-be01-b44ad0d19897", "name": "a-ha", "comment": "Norwegian synth\u2010pop band", "type": "Group", "gender": null, "score": 792, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}]

View File

@@ -0,0 +1 @@
[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"aha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"aha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You GoGo","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"ef4c6855-949e-4e22-b41e-8e0a2d372d5f","recording_name":"Tainted Love","artist_credit_name":"Soft Cell","artist_credit_mbids":null,"release_name":"Non-Stop Erotic Cabaret","release_mbid":"1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1","caa_id":1031647403,"caa_release_mbid":"c3367d3a-2f6c-48d1-95c5-c1ee7a49c479","score":61,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5","recording_name":"Everybody Wants to Rule the World","artist_credit_name":"Tears for Fears","artist_credit_mbids":null,"release_name":"Songs From the Big Chair","release_mbid":"21f19b06-81f1-347a-add5-5d0c77696597","caa_id":19682986993,"caa_release_mbid":"9aefc6dd-216a-4271-ada1-d9cf67956f39","score":68,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}]

View File

@@ -0,0 +1 @@
[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"aha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You GoGo","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}]

View File

@@ -0,0 +1,81 @@
[
{
"artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"],
"artist_name": "Mili",
"artists": [
{
"artist_credit_name": "Mili",
"artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
"join_phrase": ""
}
],
"caa_id": 14987576054,
"caa_release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408",
"length": 211912,
"recording_mbid": "9980309d-3480-4e7e-89ce-fce971a452be",
"recording_name": "world.execute(me);",
"release_color": { "blue": 109, "green": 94, "red": 95 },
"release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408",
"release_name": "Miracle Milk",
"tags": [
{
"count": 1,
"genre_mbid": "911c7bbb-172d-4df8-9478-dbff4296e791",
"tag": "pop"
},
{
"count": 1,
"genre_mbid": "b739a895-85ed-4ad3-8717-4e9ef5387dd8",
"tag": "dance-pop"
},
{
"count": 1,
"genre_mbid": "9c8ba153-740e-4b88-b7ff-31d004944c95",
"tag": "nerdcore"
},
{
"count": 1,
"genre_mbid": "c4a69842-f891-4569-9506-1882aa5db433",
"tag": "electronic rock"
},
{ "count": 1, "tag": "hackercore" },
{ "count": 1, "tag": "meter:4/4" },
{ "count": 1, "tag": "vocal:true" },
{ "count": 1, "tag": "bpm:130" },
{
"count": 1,
"genre_mbid": "e5bba957-8c91-496a-a675-c6d0c6b51c33",
"tag": "dance"
},
{
"count": 1,
"genre_mbid": "89255676-1f14-4dd8-bbad-fca839d6aff4",
"tag": "electronic"
}
],
"total_listen_count": 19440,
"total_user_count": 1102
},
{
"artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"],
"artist_name": "Mili",
"artists": [
{
"artist_credit_name": "Mili",
"artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
"join_phrase": ""
}
],
"caa_id": 31388973421,
"caa_release_mbid": "e58ed9ef-2bc1-4480-9d6d-2d799beb5ba9",
"length": 174000,
"recording_mbid": "afa2c83d-b17f-4029-b9da-790ea9250cf9",
"recording_name": "String Theocracy",
"release_color": { "blue": 92, "green": 147, "red": 164 },
"release_mbid": "d79a38e3-7016-4f39-a31a-f495ce914b8e",
"release_name": "String Theocracy",
"tags": [],
"total_listen_count": 8986,
"total_user_count": 712
}
]