mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix: stabilize STRM and cloud playback
This commit is contained in:
@@ -22,8 +22,8 @@ import (
|
||||
const EnvPrefix = "MEDIASTATION"
|
||||
|
||||
const (
|
||||
defaultDatabaseMaxOpenConns = 4
|
||||
defaultDatabaseMaxIdleConns = 2
|
||||
defaultDatabaseMaxOpenConns = 1
|
||||
defaultDatabaseMaxIdleConns = 1
|
||||
)
|
||||
|
||||
// Config 是根配置聚合。
|
||||
@@ -316,7 +316,7 @@ func (c *Config) normalize() error {
|
||||
if c.App.MaxCPUThreads > 8 {
|
||||
c.App.MaxCPUThreads = 8
|
||||
}
|
||||
if c.Database.MaxOpenConns <= 1 {
|
||||
if c.Database.MaxOpenConns <= 0 {
|
||||
c.Database.MaxOpenConns = defaultDatabaseMaxOpenConns
|
||||
}
|
||||
if c.Database.MaxIdleConns <= 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns {
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestEnvOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHealsHistoricalSingleConnectionDatabaseConfig(t *testing.T) {
|
||||
func TestLoadAllowsSingleConnectionDatabaseConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wd, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
@@ -88,6 +88,6 @@ func TestLoadHealsHistoricalSingleConnectionDatabaseConfig(t *testing.T) {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
|
||||
t.Fatalf("expected historical MaxOpenConns=1 to heal to %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
|
||||
t.Fatalf("expected MaxOpenConns=1 to remain at safe default %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,7 +757,7 @@ func embyAttachRequestTokenToPlaybackInfo(c *gin.Context, out map[string]any) {
|
||||
return
|
||||
}
|
||||
for _, source := range sources {
|
||||
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl"} {
|
||||
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl", "Path"} {
|
||||
raw, ok := source[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@@ -589,6 +589,74 @@ func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Cloud Movie",
|
||||
Path: "cloud://openlist/Movies/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/user-1/items/cloud-1/playbackinfo", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode playback info: %v", err)
|
||||
}
|
||||
source := body["MediaSources"].([]any)[0].(map[string]any)
|
||||
pathURL, _ := source["Path"].(string)
|
||||
if !strings.HasPrefix(pathURL, "/Videos/cloud-1/stream") || !strings.Contains(pathURL, "api_key=") {
|
||||
t.Fatalf("cloud Path should be tokenized stream URL, got %#v", source)
|
||||
}
|
||||
if strings.Contains(pathURL, "/api/cloud/play/") {
|
||||
t.Fatalf("cloud Path should not expose naked cloud play URL: %#v", source)
|
||||
}
|
||||
directURL, _ := source["DirectStreamUrl"].(string)
|
||||
if !strings.HasPrefix(directURL, "/Videos/cloud-1/stream.") || !strings.Contains(directURL, "api_key=") {
|
||||
t.Fatalf("DirectStreamUrl should stay tokenized: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
@@ -118,12 +119,13 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||
strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg)
|
||||
}
|
||||
res, err := strmSvc.GenerateForLibrary(c.Request.Context(), service.GenerateSTRMOptions{
|
||||
LibraryID: req.LibraryID,
|
||||
OutputDir: req.OutputDir,
|
||||
BaseURL: req.BaseURL,
|
||||
Enabled: req.Enabled,
|
||||
Overwrite: req.Overwrite,
|
||||
IncludeLocal: true,
|
||||
LibraryID: req.LibraryID,
|
||||
OutputDir: req.OutputDir,
|
||||
BaseURL: req.BaseURL,
|
||||
Enabled: req.Enabled,
|
||||
Overwrite: req.Overwrite,
|
||||
IncludeLocal: true,
|
||||
PlaybackToken: strmPlaybackTokenForRequest(c, svc),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -132,3 +134,22 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func strmPlaybackTokenForRequest(c *gin.Context, svc *service.Container) string {
|
||||
if svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil {
|
||||
return ""
|
||||
}
|
||||
uid := middleware.GetUserID(c)
|
||||
if uid == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid)
|
||||
if err != nil || u == nil {
|
||||
return ""
|
||||
}
|
||||
token, err := svc.Auth.IssueEmbyToken(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -1670,7 +1670,8 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
||||
"MediaStreams": e.mediaStreams(m),
|
||||
}
|
||||
if !asEmbedded {
|
||||
src["DirectStreamUrl"] = embyDirectStreamURL(m.ID, container)
|
||||
streamURL := embyDirectStreamURL(m.ID, container)
|
||||
src["DirectStreamUrl"] = streamURL
|
||||
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
||||
// 宿主机不参与转码。
|
||||
if !directOnly {
|
||||
@@ -1678,13 +1679,12 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" {
|
||||
// STRM / cloud:// media still plays through /Videos/{id}/stream.
|
||||
// That route delegates to StreamService, which appends the caller's
|
||||
// token to internal /api/cloud/play redirects and only then 302s to the
|
||||
// provider/CDN. Returning m.STRMURL directly here would make Emby/Yamby
|
||||
// clients hit /api/cloud/play without an auth token and fail with 401.
|
||||
// STRM / cloud:// media plays through /Videos/{id}/stream. Some
|
||||
// third-party Emby clients still prefer MediaSource.Path even when
|
||||
// SupportsDirectPlay=false; pointing Path at the token-aware stream
|
||||
// endpoint keeps those clients away from naked /api/cloud/play URLs.
|
||||
src["IsRemote"] = true
|
||||
src["Path"] = m.STRMURL
|
||||
src["Path"] = embyDirectStreamURL(m.ID, container)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
@@ -363,8 +363,8 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
|
||||
if src["DirectStreamUrl"] != "/Videos/cloud-1/stream" {
|
||||
t.Fatalf("strm playback must stay behind token-aware stream endpoint: %#v", src)
|
||||
}
|
||||
if src["Path"] != "/api/cloud/play/quark?ref=f1" {
|
||||
t.Fatalf("path should expose the strm target for diagnostics: %#v", src)
|
||||
if src["Path"] != "/Videos/cloud-1/stream" {
|
||||
t.Fatalf("path should use token-aware stream endpoint: %#v", src)
|
||||
}
|
||||
streams := src["MediaStreams"].([]map[string]any)
|
||||
if len(streams) == 0 || streams[0]["Type"] != "Video" {
|
||||
|
||||
@@ -58,9 +58,9 @@ func NewStreamService(cfg *config.Config, log *zap.Logger, repo *repository.Cont
|
||||
var ErrMediaNotFound = errors.New("media not found")
|
||||
|
||||
// ErrCloudPlaybackUnavailable 表示媒体行存在但属于云盘媒体、且当前无法
|
||||
// 构造可用的播放重定向(例如 STRM 播放被关闭或 STRMURL 缺失)。调用方
|
||||
// 应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。
|
||||
var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: strm playback disabled or media missing play url; re-scan the library or enable strm playback")
|
||||
// 构造可用的播放重定向(通常是 STRMURL 缺失,需要重新扫描媒体库)。
|
||||
// 调用方应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。
|
||||
var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: media missing play url; re-scan the library")
|
||||
|
||||
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
||||
//
|
||||
@@ -234,9 +234,9 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
if m == nil {
|
||||
return ErrMediaNotFound
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" && STRMPlaybackEnabled(r.Context(), s.repo) {
|
||||
if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && (isCloudPlaybackTarget(strmURL) || STRMPlaybackEnabled(r.Context(), s.repo)) {
|
||||
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
|
||||
target := normalizeCloudPlayTarget(m.STRMURL)
|
||||
target := normalizeCloudPlayTarget(strmURL)
|
||||
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
||||
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
||||
return nil
|
||||
@@ -263,6 +263,11 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCloudPlaybackTarget(raw string) bool {
|
||||
_, _, ok := parseCloudMediaPlaybackURL(raw)
|
||||
return ok
|
||||
}
|
||||
|
||||
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
||||
// We block (with a 30s timeout) until the playlist file shows up.
|
||||
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
||||
|
||||
@@ -85,7 +85,37 @@ func TestServeFileRedirectsInternalSTRMAsAbsoluteURLWithToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFileHonorsSTRMPlaybackDisabled(t *testing.T) {
|
||||
func TestServeFileRedirectUsesForwardedTunnelHost(t *testing.T) {
|
||||
repos := newStreamTestRepo(t)
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
Title: "Cloud",
|
||||
Path: "cloud://openlist/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=movie",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/api/stream/cloud-1?api_key=jwt123", nil)
|
||||
req.Header.Set("X-Forwarded-Host", "media.example.com")
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
if err := svc.ServeFile(w, req, "cloud-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "https://media.example.com/api/cloud/play/openlist?") ||
|
||||
!strings.Contains(loc, "ref=movie") ||
|
||||
!strings.Contains(loc, "token=jwt123") {
|
||||
t.Fatalf("redirect Location should use forwarded tunnel host and token, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFileRedirectsCloudMediaWhenSTRMPlaybackDisabled(t *testing.T) {
|
||||
repos := newStreamTestRepo(t)
|
||||
if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -103,13 +133,15 @@ func TestServeFileHonorsSTRMPlaybackDisabled(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := svc.ServeFile(w, req, "cloud-1")
|
||||
// 云盘媒体在 STRM 播放关闭时返回明确的「云盘播放不可用」错误,
|
||||
// 而不是和「媒体不存在」混在一起(后者会让播放器显示 404)。
|
||||
if err != ErrCloudPlaybackUnavailable {
|
||||
t.Fatalf("disabled STRM should not redirect cloud media, err=%v status=%d location=%q", err, w.Code, w.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("cloud playback should ignore local STRM toggle: %v", err)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "" {
|
||||
t.Fatalf("disabled STRM leaked redirect Location %q", loc)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "/api/cloud/play/openlist?") || !strings.Contains(loc, "token=jwt123") {
|
||||
t.Fatalf("redirect Location should target tokenized cloud play endpoint, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
@@ -36,12 +37,13 @@ type STRMService struct {
|
||||
}
|
||||
|
||||
type GenerateSTRMOptions struct {
|
||||
LibraryID string `json:"library_id"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
IncludeLocal bool `json:"include_local"`
|
||||
LibraryID string `json:"library_id"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
IncludeLocal bool `json:"include_local"`
|
||||
PlaybackToken string `json:"-"`
|
||||
}
|
||||
|
||||
type GenerateSTRMResult struct {
|
||||
@@ -146,7 +148,7 @@ func (s *STRMService) defaultOutputDir(lib *model.Library) string {
|
||||
|
||||
func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media model.Media, outputDir string, opts GenerateSTRMOptions) GenerateSTRMItem {
|
||||
item := GenerateSTRMItem{MediaID: media.ID, Title: media.Title}
|
||||
playURL := s.strmPlaybackURL(ctx, media, opts.BaseURL)
|
||||
playURL := s.strmPlaybackURL(ctx, media, opts.BaseURL, opts.PlaybackToken)
|
||||
if playURL == "" {
|
||||
item.Action = "skipped"
|
||||
item.Reason = "no playable strm target"
|
||||
@@ -185,19 +187,68 @@ func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media
|
||||
item.Reason = err.Error()
|
||||
return item
|
||||
}
|
||||
_ = s.upsertGeneratedRecord(ctx, media, filePath, playURL, lib.Type)
|
||||
if err := s.upsertGeneratedRecord(ctx, media, filePath, playURL, lib.Type); err != nil {
|
||||
item.Action = "error"
|
||||
item.Reason = err.Error()
|
||||
return item
|
||||
}
|
||||
item.Action = action
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL string) string {
|
||||
if raw := strings.TrimSpace(media.STRMURL); raw != "" {
|
||||
return absolutizeSTRMURL(raw, firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)))
|
||||
}
|
||||
func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL, playbackToken string) string {
|
||||
if media.ID == "" {
|
||||
return ""
|
||||
}
|
||||
return buildAbsoluteSTRMAPIURL(firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)), "/api/stream/"+url.PathEscape(media.ID), nil)
|
||||
query := url.Values{}
|
||||
token := strings.TrimSpace(playbackToken)
|
||||
if token == "" {
|
||||
token = s.defaultSTRMPlaybackToken(ctx)
|
||||
}
|
||||
if token != "" {
|
||||
query.Set("token", token)
|
||||
}
|
||||
return buildAbsoluteSTRMAPIURL(firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)), "/api/stream/"+url.PathEscape(media.ID), query)
|
||||
}
|
||||
|
||||
func (s *STRMService) defaultSTRMPlaybackToken(ctx context.Context) string {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Secrets.JWTSecret) == "" {
|
||||
return ""
|
||||
}
|
||||
admin, err := s.repo.User.FirstAdmin(ctx)
|
||||
if err != nil || admin == nil {
|
||||
if err != nil && s.log != nil {
|
||||
s.log.Warn("generate strm playback token failed", zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
token, err := signSTRMPlaybackToken(admin, s.cfg.Secrets.JWTSecret)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("sign strm playback token failed", zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func signSTRMPlaybackToken(u *model.User, secret string) (string, error) {
|
||||
if u == nil || strings.TrimSpace(u.ID) == "" || strings.TrimSpace(secret) == "" {
|
||||
return "", ErrSTRMURLInvalid
|
||||
}
|
||||
claims := Claims{
|
||||
UserID: u.ID,
|
||||
Role: u.Role,
|
||||
Tier: u.Tier,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)),
|
||||
Issuer: "mediastationgo",
|
||||
Subject: u.ID,
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) string {
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -41,10 +43,11 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
||||
svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
|
||||
|
||||
res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
PlaybackToken: "strm-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -54,8 +57,8 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
||||
}
|
||||
cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm")
|
||||
localSTRM := filepath.Join(outDir, "本地电影 (2025)", "本地电影 (2025).strm")
|
||||
assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/cloud/play/openlist?ref=movie")
|
||||
assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media")
|
||||
assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/stream/cloud-media?token=strm-token")
|
||||
assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media?token=strm-token")
|
||||
|
||||
var count int64
|
||||
if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil {
|
||||
@@ -66,10 +69,11 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
||||
}
|
||||
|
||||
res, err = svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
PlaybackToken: "strm-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -79,13 +83,76 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSTRMForLibrarySignsDefaultPlaybackToken(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}, &model.User{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
admin := model.User{Username: "admin", PasswordHash: "x", Role: "admin", Tier: "plus", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), &admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
outDir := filepath.Join(t.TempDir(), "strm")
|
||||
const secret = "test-secret"
|
||||
svc := NewSTRMService(zap.NewNop(), repos, &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}})
|
||||
res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Generated != 1 || len(res.Errors) != 0 {
|
||||
t.Fatalf("result = %#v, want generated=1 with no errors", res)
|
||||
}
|
||||
cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm")
|
||||
got := readSTRM(t, cloudSTRM)
|
||||
if !strings.HasPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=") {
|
||||
t.Fatalf("generated url = %q, want tokenized /api/stream url", got)
|
||||
}
|
||||
token := strings.TrimPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=")
|
||||
claims := &Claims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
t.Fatalf("generated token did not validate: %v", err)
|
||||
}
|
||||
if claims.UserID != admin.ID || claims.Role != "admin" || claims.Tier != "plus" {
|
||||
t.Fatalf("claims = %#v, want admin identity", claims)
|
||||
}
|
||||
if ttl := time.Until(claims.ExpiresAt.Time); ttl < EmbyTokenDuration-time.Minute {
|
||||
t.Fatalf("token ttl = %v, want close to %v", ttl, EmbyTokenDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContains(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
if got := readSTRM(t, path); got != want {
|
||||
t.Fatalf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func readSTRM(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(data)); got != want {
|
||||
t.Fatalf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ export function StrmPage() {
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-sand-500">
|
||||
生成内容为 <code>域名 + /api/stream/媒体ID</code> 或网盘 302 播放入口;域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
|
||||
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;本地文件与网盘 302 播放都由服务端统一处理。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
|
||||
</p>
|
||||
{generateResult && (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 text-sm text-ink-50">
|
||||
|
||||
Reference in New Issue
Block a user