mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
* fix(share): reuse cached transcodes when streaming from share links Public share streams built the stream request with only the share's format and bit rate, leaving sample rate, bit depth and channels at zero. Regular playback resolves those through the transcode decider (e.g. 48000 Hz for Opus), and they are part of the transcoding cache key, so a track already transcoded during normal playback was transcoded again into a separate, identical cache entry when played through a share link. The public router now resolves share stream requests with the same TranscodeDecider.ResolveRequest used by the Subsonic stream endpoint, so both paths produce the same request and share cache entries. Fixes #6261 * fix(archiver): reuse cached transcodes when zipping downloads Zip downloads (album, artist, playlist and share) built the stream request with only the format and bit rate, leaving sample rate, bit depth and channels at zero. Those are part of the transcoding cache key, so a track already transcoded for playback was transcoded again into a separate cache entry when downloaded in a zip, and vice versa. The archiver now resolves each request with TranscodeDecider.ResolveRequest, the same as single-song downloads and streams. This also applies the decider's defaults, so a zip requested without a bit rate uses the target format's default bit rate instead of leaving it to ffmpeg. * fix(archiver): name zip entries after the resolved transcoding format The transcode decider can pick a different format than the one requested (for example a player's forced transcoding, or a fallback to the default downsampling format when the requested one can't be produced). Zip entry names and the playlist M3U were still built from the requested format, so an entry could end in .mp3 or .flac while holding Opus data. Each track's request is now resolved before its entry name is built, and the name uses the resolved format.
259 lines
8.9 KiB
Go
259 lines
8.9 KiB
Go
package public
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"time"
|
|
|
|
"github.com/go-chi/jwtauth/v5"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
type mockStreamer struct {
|
|
req stream.Request
|
|
called bool
|
|
}
|
|
|
|
func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) {
|
|
m.called = true
|
|
m.req = r
|
|
return nil, errors.New("mock: not implemented")
|
|
}
|
|
|
|
var _ = Describe("decodeStreamInfo", func() {
|
|
BeforeEach(func() {
|
|
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
})
|
|
|
|
It("decodes a valid token with all fields", func() {
|
|
claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.id).To(Equal("mf-123"))
|
|
Expect(info.format).To(Equal("mp3"))
|
|
Expect(info.bitrate).To(Equal(192))
|
|
Expect(info.shareID).To(Equal("share123"))
|
|
})
|
|
|
|
It("rejects an expired token", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("accepts a token without exp (non-expiring share)", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.id).To(Equal("mf-123"))
|
|
Expect(info.shareID).To(Equal("share123"))
|
|
})
|
|
|
|
It("rejects a token without an id claim", func() {
|
|
claims := auth.Claims{ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("rejects an invalid token string", func() {
|
|
_, err := decodeStreamInfo("not-a-valid-token")
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("rejects a token without a shareID claim", func() {
|
|
claims := auth.Claims{ID: "mf-123", Format: "opus"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("encodeMediafileShare", func() {
|
|
BeforeEach(func() {
|
|
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
})
|
|
|
|
It("includes the share ID in the token", func() {
|
|
exp := new(time.Now().Add(time.Hour))
|
|
s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp}
|
|
token := encodeMediafileShare(s, "mf-999")
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.shareID).To(Equal("shareABC"))
|
|
Expect(info.id).To(Equal("mf-999"))
|
|
Expect(info.format).To(Equal("mp3"))
|
|
Expect(info.bitrate).To(Equal(320))
|
|
})
|
|
|
|
It("creates a non-expiring token when share has no expiry", func() {
|
|
s := model.Share{ID: "shareXYZ", ExpiresAt: nil}
|
|
token := encodeMediafileShare(s, "mf-111")
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.shareID).To(Equal("shareXYZ"))
|
|
Expect(info.id).To(Equal("mf-111"))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("handleStream", func() {
|
|
var ctx context.Context
|
|
var ds *tests.MockDataStore
|
|
var shareRepo *tests.MockShareRepo
|
|
var streamer *mockStreamer
|
|
var pub *Router
|
|
|
|
BeforeEach(func() {
|
|
ctx = GinkgoT().Context()
|
|
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
|
shareRepo = &tests.MockShareRepo{}
|
|
ds.MockedShare = shareRepo
|
|
streamer = &mockStreamer{}
|
|
pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))}
|
|
})
|
|
|
|
makeRequest := func(token string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil)
|
|
w := httptest.NewRecorder()
|
|
pub.handleStream(w, r)
|
|
return w
|
|
}
|
|
|
|
shareOwnedBy := func(owner model.User, mf model.MediaFile) {
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}}
|
|
userRepo := tests.CreateMockUserRepo()
|
|
Expect(userRepo.Put(ctx, &owner)).To(Succeed())
|
|
ds.MockedUser = userRepo
|
|
mfRepo := tests.CreateMockMediaFileRepo()
|
|
mfRepo.SetData(model.MediaFiles{mf})
|
|
ds.MockedMediaFile = mfRepo
|
|
}
|
|
|
|
It("passes all validation and reaches the streamer for a valid token", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", IsAdmin: true},
|
|
model.MediaFile{ID: "mf-123", Title: "Test Song"},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
makeRequest(token)
|
|
|
|
Expect(streamer.called).To(BeTrue())
|
|
})
|
|
|
|
It("resolves the full stream request like the Subsonic endpoint, so transcodes share the cache", func() {
|
|
mf := model.MediaFile{ID: "mf-123", Suffix: "flac", BitRate: 1500, SampleRate: 44100, BitDepth: new(24), Channels: 2}
|
|
shareOwnedBy(model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, mf)
|
|
|
|
claims := auth.Claims{ID: "mf-123", Format: "opus", BitRate: 128, ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
makeRequest(token)
|
|
|
|
Expect(streamer.req).To(Equal(stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}))
|
|
})
|
|
|
|
It("returns 404 when the track is outside the share owner's libraries", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}},
|
|
model.MediaFile{ID: "mf-restricted", Title: "Other Lib Track", LibraryID: 2},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-restricted", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("returns 404 when the track is not a member of the share", func() {
|
|
owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}
|
|
userRepo := tests.CreateMockUserRepo()
|
|
Expect(userRepo.Put(ctx, &owner)).To(Succeed())
|
|
ds.MockedUser = userRepo
|
|
mfRepo := tests.CreateMockMediaFileRepo()
|
|
mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}})
|
|
ds.MockedMediaFile = mfRepo
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}}
|
|
|
|
claims := auth.Claims{ID: "mf-other", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("streams a track inside the share owner's libraries", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}},
|
|
model.MediaFile{ID: "mf-ok", Title: "OK", LibraryID: 1},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-ok", Format: "mp3", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
makeRequest(token)
|
|
|
|
Expect(streamer.called).To(BeTrue())
|
|
})
|
|
|
|
It("returns 400 for an expired token", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 404 when share has been deleted", func() {
|
|
shareRepo.ID = "other-share"
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 410 when share has been set to expired", func() {
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))}
|
|
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
})
|
|
|
|
It("returns 500 when share lookup fails", func() {
|
|
shareRepo.Error = errors.New("db error")
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
|
|
It("returns 400 for tokens without a shareID", func() {
|
|
claims := auth.Claims{ID: "mf-123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("returns 400 for an invalid token", func() {
|
|
w := makeRequest("not-a-valid-token")
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
})
|