mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 20:46:37 +08:00
fix: stabilize STRM and cloud playback
This commit is contained in:
@@ -22,8 +22,8 @@ import (
|
|||||||
const EnvPrefix = "MEDIASTATION"
|
const EnvPrefix = "MEDIASTATION"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDatabaseMaxOpenConns = 4
|
defaultDatabaseMaxOpenConns = 1
|
||||||
defaultDatabaseMaxIdleConns = 2
|
defaultDatabaseMaxIdleConns = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config 是根配置聚合。
|
// Config 是根配置聚合。
|
||||||
@@ -316,7 +316,7 @@ func (c *Config) normalize() error {
|
|||||||
if c.App.MaxCPUThreads > 8 {
|
if c.App.MaxCPUThreads > 8 {
|
||||||
c.App.MaxCPUThreads = 8
|
c.App.MaxCPUThreads = 8
|
||||||
}
|
}
|
||||||
if c.Database.MaxOpenConns <= 1 {
|
if c.Database.MaxOpenConns <= 0 {
|
||||||
c.Database.MaxOpenConns = defaultDatabaseMaxOpenConns
|
c.Database.MaxOpenConns = defaultDatabaseMaxOpenConns
|
||||||
}
|
}
|
||||||
if c.Database.MaxIdleConns <= 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns {
|
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()
|
dir := t.TempDir()
|
||||||
wd, _ := os.Getwd()
|
wd, _ := os.Getwd()
|
||||||
defer func() { _ = os.Chdir(wd) }()
|
defer func() { _ = os.Chdir(wd) }()
|
||||||
@@ -88,6 +88,6 @@ func TestLoadHealsHistoricalSingleConnectionDatabaseConfig(t *testing.T) {
|
|||||||
t.Fatalf("Load() error: %v", err)
|
t.Fatalf("Load() error: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
|
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
|
return
|
||||||
}
|
}
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl"} {
|
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl", "Path"} {
|
||||||
raw, ok := source[key].(string)
|
raw, ok := source[key].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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) {
|
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"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)
|
strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg)
|
||||||
}
|
}
|
||||||
res, err := strmSvc.GenerateForLibrary(c.Request.Context(), service.GenerateSTRMOptions{
|
res, err := strmSvc.GenerateForLibrary(c.Request.Context(), service.GenerateSTRMOptions{
|
||||||
LibraryID: req.LibraryID,
|
LibraryID: req.LibraryID,
|
||||||
OutputDir: req.OutputDir,
|
OutputDir: req.OutputDir,
|
||||||
BaseURL: req.BaseURL,
|
BaseURL: req.BaseURL,
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
Overwrite: req.Overwrite,
|
Overwrite: req.Overwrite,
|
||||||
IncludeLocal: true,
|
IncludeLocal: true,
|
||||||
|
PlaybackToken: strmPlaybackTokenForRequest(c, svc),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
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)
|
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),
|
"MediaStreams": e.mediaStreams(m),
|
||||||
}
|
}
|
||||||
if !asEmbedded {
|
if !asEmbedded {
|
||||||
src["DirectStreamUrl"] = embyDirectStreamURL(m.ID, container)
|
streamURL := embyDirectStreamURL(m.ID, container)
|
||||||
|
src["DirectStreamUrl"] = streamURL
|
||||||
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
||||||
// 宿主机不参与转码。
|
// 宿主机不参与转码。
|
||||||
if !directOnly {
|
if !directOnly {
|
||||||
@@ -1678,13 +1679,12 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(m.STRMURL) != "" {
|
if strings.TrimSpace(m.STRMURL) != "" {
|
||||||
// STRM / cloud:// media still plays through /Videos/{id}/stream.
|
// STRM / cloud:// media plays through /Videos/{id}/stream. Some
|
||||||
// That route delegates to StreamService, which appends the caller's
|
// third-party Emby clients still prefer MediaSource.Path even when
|
||||||
// token to internal /api/cloud/play redirects and only then 302s to the
|
// SupportsDirectPlay=false; pointing Path at the token-aware stream
|
||||||
// provider/CDN. Returning m.STRMURL directly here would make Emby/Yamby
|
// endpoint keeps those clients away from naked /api/cloud/play URLs.
|
||||||
// clients hit /api/cloud/play without an auth token and fail with 401.
|
|
||||||
src["IsRemote"] = true
|
src["IsRemote"] = true
|
||||||
src["Path"] = m.STRMURL
|
src["Path"] = embyDirectStreamURL(m.ID, container)
|
||||||
}
|
}
|
||||||
return src
|
return src
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,8 +363,8 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
|
|||||||
if src["DirectStreamUrl"] != "/Videos/cloud-1/stream" {
|
if src["DirectStreamUrl"] != "/Videos/cloud-1/stream" {
|
||||||
t.Fatalf("strm playback must stay behind token-aware stream endpoint: %#v", src)
|
t.Fatalf("strm playback must stay behind token-aware stream endpoint: %#v", src)
|
||||||
}
|
}
|
||||||
if src["Path"] != "/api/cloud/play/quark?ref=f1" {
|
if src["Path"] != "/Videos/cloud-1/stream" {
|
||||||
t.Fatalf("path should expose the strm target for diagnostics: %#v", src)
|
t.Fatalf("path should use token-aware stream endpoint: %#v", src)
|
||||||
}
|
}
|
||||||
streams := src["MediaStreams"].([]map[string]any)
|
streams := src["MediaStreams"].([]map[string]any)
|
||||||
if len(streams) == 0 || streams[0]["Type"] != "Video" {
|
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")
|
var ErrMediaNotFound = errors.New("media not found")
|
||||||
|
|
||||||
// ErrCloudPlaybackUnavailable 表示媒体行存在但属于云盘媒体、且当前无法
|
// ErrCloudPlaybackUnavailable 表示媒体行存在但属于云盘媒体、且当前无法
|
||||||
// 构造可用的播放重定向(例如 STRM 播放被关闭或 STRMURL 缺失)。调用方
|
// 构造可用的播放重定向(通常是 STRMURL 缺失,需要重新扫描媒体库)。
|
||||||
// 应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。
|
// 调用方应把它与「媒体不存在」区分开,避免把配置类故障当成 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")
|
var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: media missing play url; re-scan the library")
|
||||||
|
|
||||||
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
||||||
//
|
//
|
||||||
@@ -234,9 +234,9 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return ErrMediaNotFound
|
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。
|
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
|
||||||
target := normalizeCloudPlayTarget(m.STRMURL)
|
target := normalizeCloudPlayTarget(strmURL)
|
||||||
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
||||||
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
||||||
return nil
|
return nil
|
||||||
@@ -263,6 +263,11 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCloudPlaybackTarget(raw string) bool {
|
||||||
|
_, _, ok := parseCloudMediaPlaybackURL(raw)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
||||||
// We block (with a 30s timeout) until the playlist file shows up.
|
// 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 {
|
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)
|
repos := newStreamTestRepo(t)
|
||||||
if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
|
if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -103,13 +133,15 @@ func TestServeFileHonorsSTRMPlaybackDisabled(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
err := svc.ServeFile(w, req, "cloud-1")
|
err := svc.ServeFile(w, req, "cloud-1")
|
||||||
// 云盘媒体在 STRM 播放关闭时返回明确的「云盘播放不可用」错误,
|
if err != nil {
|
||||||
// 而不是和「媒体不存在」混在一起(后者会让播放器显示 404)。
|
t.Fatalf("cloud playback should ignore local STRM toggle: %v", err)
|
||||||
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 loc := w.Header().Get("Location"); loc != "" {
|
if w.Code != http.StatusFound {
|
||||||
t.Fatalf("disabled STRM leaked redirect Location %q", loc)
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
@@ -36,12 +37,13 @@ type STRMService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GenerateSTRMOptions struct {
|
type GenerateSTRMOptions struct {
|
||||||
LibraryID string `json:"library_id"`
|
LibraryID string `json:"library_id"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
BaseURL string `json:"base_url,omitempty"`
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Overwrite bool `json:"overwrite"`
|
Overwrite bool `json:"overwrite"`
|
||||||
IncludeLocal bool `json:"include_local"`
|
IncludeLocal bool `json:"include_local"`
|
||||||
|
PlaybackToken string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GenerateSTRMResult struct {
|
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 {
|
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}
|
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 == "" {
|
if playURL == "" {
|
||||||
item.Action = "skipped"
|
item.Action = "skipped"
|
||||||
item.Reason = "no playable strm target"
|
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()
|
item.Reason = err.Error()
|
||||||
return item
|
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
|
item.Action = action
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL string) string {
|
func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL, playbackToken string) string {
|
||||||
if raw := strings.TrimSpace(media.STRMURL); raw != "" {
|
|
||||||
return absolutizeSTRMURL(raw, firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)))
|
|
||||||
}
|
|
||||||
if media.ID == "" {
|
if media.ID == "" {
|
||||||
return ""
|
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 {
|
func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) string {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
@@ -41,10 +43,11 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
|||||||
svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
|
svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
|
||||||
|
|
||||||
res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
||||||
LibraryID: lib.ID,
|
LibraryID: lib.ID,
|
||||||
OutputDir: outDir,
|
OutputDir: outDir,
|
||||||
BaseURL: "http://nas.example:18080",
|
BaseURL: "http://nas.example:18080",
|
||||||
IncludeLocal: true,
|
IncludeLocal: true,
|
||||||
|
PlaybackToken: "strm-token",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -54,8 +57,8 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm")
|
cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm")
|
||||||
localSTRM := filepath.Join(outDir, "本地电影 (2025)", "本地电影 (2025).strm")
|
localSTRM := filepath.Join(outDir, "本地电影 (2025)", "本地电影 (2025).strm")
|
||||||
assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/cloud/play/openlist?ref=movie")
|
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")
|
assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media?token=strm-token")
|
||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil {
|
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{
|
res, err = svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
||||||
LibraryID: lib.ID,
|
LibraryID: lib.ID,
|
||||||
OutputDir: outDir,
|
OutputDir: outDir,
|
||||||
BaseURL: "http://nas.example:18080",
|
BaseURL: "http://nas.example:18080",
|
||||||
IncludeLocal: true,
|
IncludeLocal: true,
|
||||||
|
PlaybackToken: "strm-token",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
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()
|
t.Helper()
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := strings.TrimSpace(string(data)); got != want {
|
return strings.TrimSpace(string(data))
|
||||||
t.Fatalf("%s = %q, want %q", path, got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export function StrmPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="text-xs text-sand-500">
|
<p className="text-xs text-sand-500">
|
||||||
生成内容为 <code>域名 + /api/stream/媒体ID</code> 或网盘 302 播放入口;域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
|
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;本地文件与网盘 302 播放都由服务端统一处理。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
|
||||||
</p>
|
</p>
|
||||||
{generateResult && (
|
{generateResult && (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 text-sm text-ink-50">
|
<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