diff --git a/internal/config/config.go b/internal/config/config.go index 066a968..9d5a2bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2fb6679..202dac6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) } } diff --git a/internal/handler/emby.go b/internal/handler/emby.go index 5225e82..4b18133 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -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 diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index 22f191d..18e2d30 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -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{}) diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 9bf931e..caf72fb 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -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 +} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index ffc2325..5068b48 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -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 } diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 91d5958..fef6c35 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -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" { diff --git a/internal/service/stream.go b/internal/service/stream.go index c05e77a..b94bfdb 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -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 { diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index 44333eb..5cce474 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -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) } } diff --git a/internal/service/strm_svc.go b/internal/service/strm_svc.go index deba942..5d16d7b 100644 --- a/internal/service/strm_svc.go +++ b/internal/service/strm_svc.go @@ -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 { diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go index 8e9b715..60675b2 100644 --- a/internal/service/strm_svc_test.go +++ b/internal/service/strm_svc_test.go @@ -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)) } diff --git a/web/src/pages/StrmPage.tsx b/web/src/pages/StrmPage.tsx index 06896c9..66cf17c 100644 --- a/web/src/pages/StrmPage.tsx +++ b/web/src/pages/StrmPage.tsx @@ -289,7 +289,7 @@ export function StrmPage() {
- 生成内容为 域名 + /api/stream/媒体ID 或网盘 302 播放入口;域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
+ 生成内容为 域名 + /api/stream/媒体ID?token=...;本地文件与网盘 302 播放都由服务端统一处理。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。