fix: stabilize STRM and cloud playback

This commit is contained in:
ShukeBta
2026-06-12 22:57:50 +08:00
parent d1a8824842
commit 5d942f2c42
12 changed files with 304 additions and 60 deletions
+3 -3
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+68
View File
@@ -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{})
+27 -6
View File
@@ -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
}
+7 -7
View File
@@ -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
}
+2 -2
View File
@@ -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" {
+10 -5
View File
@@ -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 {
+39 -7
View File
@@ -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)
}
}
+64 -13
View File
@@ -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 {
+80 -13
View File
@@ -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))
}
+1 -1
View File
@@ -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">