添加strm生成

添加strm
This commit is contained in:
truewhile
2026-08-24 14:36:17 +08:00
parent cd1297a30f
commit c55d29ae84
47 changed files with 11956 additions and 4 deletions
+6
View File
@@ -20,6 +20,12 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
api.GET("/version", versionInfo)
api.GET("/public/ui-config", publicUIConfigHandler(svc))
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
registerPublicAuthRoutes(api, svc, log)
registerAuthenticatedRoutes(api, cfg, svc)
+36
View File
@@ -19,6 +19,42 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
registerAdminOrganizerRoutes(admin, svc)
registerAdminAPIConfigRoutes(admin, svc)
registerAdminRecognitionWordRoutes(admin, svc)
registerAdminStrmRoutes(admin, svc)
}
func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/strm/accounts", listStrmAccountsHandler(svc))
admin.POST("/strm/accounts", createStrmAccountHandler(svc))
admin.PUT("/strm/accounts/:id", updateStrmAccountHandler(svc))
admin.DELETE("/strm/accounts/:id", deleteStrmAccountHandler(svc))
admin.POST("/strm/accounts/:id/test", testStrmAccountHandler(svc))
admin.GET("/strm/accounts/:id/list", listStrmRemoteDirHandler(svc))
admin.GET("/strm/115/sources", listStrm115SourcesHandler(svc))
admin.POST("/strm/accounts/:id/oauth/start", startStrm115OAuthHandler(svc))
admin.POST("/strm/accounts/:id/oauth/poll", pollStrm115OAuthHandler(svc))
admin.GET("/strm/settings", getStrmSettingsHandler(svc))
admin.PUT("/strm/settings", updateStrmSettingsHandler(svc))
admin.GET("/strm/paths", listStrmSyncPathsHandler(svc))
admin.POST("/strm/paths", createStrmSyncPathHandler(svc))
admin.PUT("/strm/paths/:id", updateStrmSyncPathHandler(svc))
admin.DELETE("/strm/paths/:id", deleteStrmSyncPathHandler(svc))
admin.POST("/strm/paths/:id/sync", startStrmSyncHandler(svc))
admin.POST("/strm/paths/:id/cancel", cancelStrmSyncHandler(svc))
admin.GET("/strm/records", listStrmSyncRecordsHandler(svc))
admin.GET("/strm/local-dirs", listStrmLocalDirsHandler(svc))
admin.GET("/strm/downloads", downloadQueueHandler(svc))
admin.POST("/strm/downloads/:id/cancel", cancelStrmDownloadHandler(svc))
admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(svc))
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
admin.POST("/strm/downloads/retry-failed", retryAllFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/cancel-pending", cancelPendingDownloadsHandler(svc))
admin.GET("/strm/uploads", uploadQueueHandler(svc))
admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc))
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
}
func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
+564
View File
@@ -0,0 +1,564 @@
// STRM 管理 HTTP 层:网盘账号 / 同步目录 / 全局设置 / 同步控制 / 下载上传队列,
// 以及公开的 strm 播放重定向端点 /api/strm/play/:provider/*filepath。
package handler
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// ─── 网盘账号 ──────────────────────────────────────────────────────────────────
type strmAccountReq struct {
Name string `json:"name"`
Provider string `json:"provider" binding:"required"`
Config map[string]string `json:"config"`
Enabled *bool `json:"enabled"`
}
type strmAccountView struct {
model.StrmAccount
HasCredential bool `json:"has_credential"`
ProviderLabel string `json:"provider_label"`
}
func strmAccountViews(accounts []model.StrmAccount) []strmAccountView {
out := make([]strmAccountView, 0, len(accounts))
for i := range accounts {
a := accounts[i]
out = append(out, strmAccountView{
StrmAccount: a,
HasCredential: service.HasStrmAccountCredential(&a),
ProviderLabel: providerLabelOf(a.Provider),
})
}
return out
}
func providerLabelOf(provider string) string {
label, ok := service.StrmProviderLabels[provider]
if !ok {
return provider
}
return label
}
func listStrmAccountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
accounts, err := svc.Strm.ListAccounts(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, strmAccountViews(accounts))
}
}
func createStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req strmAccountReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
acct, err := svc.Strm.CreateStrmAccount(c.Request.Context(), req.Name, req.Provider, req.Config)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
views := strmAccountViews([]model.StrmAccount{*acct})
c.JSON(http.StatusOK, views[0])
}
}
func updateStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
var req strmAccountReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
acct, err := svc.Strm.UpdateStrmAccount(c.Request.Context(), id, req.Name, req.Enabled, req.Config)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
views := strmAccountViews([]model.StrmAccount{*acct})
c.JSON(http.StatusOK, views[0])
}
}
func deleteStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.DeleteStrmAccount(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
acct := svc.Strm.TestStrmAccount(c.Request.Context(), c.Param("id"))
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
return
}
views := strmAccountViews([]model.StrmAccount{*acct})
c.JSON(http.StatusOK, views[0])
}
}
func listStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
dir := strings.TrimSpace(c.Query("dir"))
entries, err := svc.Strm.ListRemoteDir(c.Request.Context(), c.Param("id"), dir)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, entries)
}
}
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
func getStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := svc.Strm.GetStrmSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
}
}
func updateStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req map[string]string
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Strm.UpdateStrmSettings(c.Request.Context(), req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// ─── 同步目录 ──────────────────────────────────────────────────────────────────
type strmSyncPathReq struct {
Name string `json:"name"`
AccountID string `json:"account_id"`
Provider string `json:"provider"`
RemotePath string `json:"remote_path"`
LocalPath string `json:"local_path"`
StrmBaseURL string `json:"strm_base_url"`
VideoExt string `json:"video_ext"`
MetaExt string `json:"meta_ext"`
ExcludeName string `json:"exclude_name"`
MinVideoSizeMB int64 `json:"min_video_size_mb"`
AddPath int `json:"add_path"`
DownloadMeta *bool `json:"download_meta"`
UploadMeta *bool `json:"upload_meta"`
DeleteDir *bool `json:"delete_dir"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
Enabled *bool `json:"enabled"`
}
type strmSyncPathView struct {
model.StrmSyncPath
AccountName string `json:"account_name"`
AccountEnabled bool `json:"account_enabled"`
}
func strmSyncPathViews(svc *service.Container, c *gin.Context, paths []model.StrmSyncPath) []strmSyncPathView {
out := make([]strmSyncPathView, 0, len(paths))
for i := range paths {
p := paths[i]
view := strmSyncPathView{StrmSyncPath: p, AccountEnabled: true}
if p.AccountID != "" {
if acct, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), p.AccountID); err == nil && acct != nil {
view.AccountName = acct.Name
view.AccountEnabled = acct.Enabled
}
}
out = append(out, view)
}
return out
}
func listStrmSyncPathsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
paths, err := svc.Strm.ListSyncPaths(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, strmSyncPathViews(svc, c, paths))
}
}
func createStrmSyncPathHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req strmSyncPathReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
path, err := svc.Strm.CreateSyncPath(c.Request.Context(), strmSyncPathFromReq(req))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
views := strmSyncPathViews(svc, c, []model.StrmSyncPath{*path})
c.JSON(http.StatusOK, views[0])
}
}
func updateStrmSyncPathHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
var req strmSyncPathReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
path, err := svc.Strm.UpdateSyncPath(c.Request.Context(), id, strmSyncPathFromReq(req))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
views := strmSyncPathViews(svc, c, []model.StrmSyncPath{*path})
c.JSON(http.StatusOK, views[0])
}
}
func deleteStrmSyncPathHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.DeleteSyncPath(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func startStrmSyncHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.StartSync(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func cancelStrmSyncHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.CancelSync(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func listStrmSyncRecordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
records, err := svc.Strm.ListSyncRecords(c.Request.Context(), c.Query("path_id"), 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, records)
}
}
// ─── 下载/上传队列 ─────────────────────────────────────────────────────────────
func downloadQueueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
snap, err := svc.Strm.DownloadQueueSnapshot(c.Request.Context(), c.Query("status"), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, snap)
}
}
func uploadQueueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
snap, err := svc.Strm.UploadQueueSnapshot(c.Request.Context(), c.Query("status"), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, snap)
}
}
func cancelStrmDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.CancelDownloadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func retryStrmDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.RetryDownloadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func cancelStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.CancelUploadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func retryStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.RetryUploadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// ─── 下载队列批量操作 ─────────────────────────────────────────────────────────
func clearDoneDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearDoneDownloadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearFinishedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearFinishedDownloadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.RetryAllFailedDownloadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"retried": n})
}
}
func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.CancelPendingDownloadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"canceled": n})
}
}
// ─── 公开播放端点 ──────────────────────────────────────────────────────────────
// strmPlayHandler 处理 strm 文件指向的播放请求(Emby/Infuse 直接请求,无 JWT)。
func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
provider := strings.TrimSpace(c.Param("provider"))
result, err := svc.Strm.ResolvePlay(c.Request.Context(), provider, url.Values(c.Request.URL.Query()))
if err != nil {
if errors.Is(err, service.ErrStrmPlayNotFound) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
switch {
case result.LocalPath != "":
c.Header("Accept-Ranges", "bytes")
c.File(result.LocalPath)
case result.Proxy && result.Link != nil:
if err := svc.Strm.ProxyDirect(c.Request.Context(), c.Writer, c.Request, result.Link); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
case result.RedirectURL != "":
c.Redirect(http.StatusFound, result.RedirectURL)
default:
c.Status(http.StatusNotFound)
}
}
}
// strmSyncPathFromReq 组装同步目录模型(缺省值交给服务层处理)。
func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
return &model.StrmSyncPath{
Name: strings.TrimSpace(req.Name),
AccountID: strings.TrimSpace(req.AccountID),
Provider: strings.TrimSpace(req.Provider),
RemotePath: strings.TrimSpace(req.RemotePath),
LocalPath: strings.TrimSpace(req.LocalPath),
StrmBaseURL: strings.TrimSpace(req.StrmBaseURL),
VideoExt: req.VideoExt,
MetaExt: req.MetaExt,
ExcludeName: req.ExcludeName,
MinVideoSizeMB: req.MinVideoSizeMB,
AddPath: req.AddPath,
DownloadMeta: boolValue(req.DownloadMeta, true),
UploadMeta: boolValue(req.UploadMeta, false),
DeleteDir: boolValue(req.DeleteDir, false),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
Enabled: boolValue(req.Enabled, true),
}
}
func boolValue(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
// ─── 115 开放平台授权 ──────────────────────────────────────────────────────────
func listStrm115SourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sources, err := svc.Strm.List115Sources(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, sources)
}
}
type strm115OAuthStartReq struct {
AuthSource string `json:"auth_source" binding:"required"` // built_in_appid / custom_appid / built_in_relay / third_party_service
AppID string `json:"app_id"`
Provider string `json:"provider"`
RedirectURL string `json:"redirect_url"`
}
type strm115OAuthPollReq struct {
SessionID string `json:"session_id" binding:"required"`
}
func startStrm115OAuthHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req strm115OAuthStartReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
redirectURL := strings.TrimSpace(req.RedirectURL)
if redirectURL == "" {
// 默认回跳本服务回调端点(中继/CloudDrive 模式需要)
redirectURL = "http://" + c.Request.Host + "/api/strm/oauth/callback"
}
result, err := svc.Strm.Start115OAuth(c.Request.Context(), c.Param("id"), req.AuthSource, req.AppID, req.Provider, redirectURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
}
func pollStrm115OAuthHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req strm115OAuthPollReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status, err := svc.Strm.Poll115OAuth(c.Request.Context(), req.SessionID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, status)
}
}
// strm115OAuthCallbackHandler 处理中继 / CloudDrive 授权回跳(公开端点,
// 无鉴权;凭 authorization_id 会话 + 共享密钥(中继)校验合法性)。
func strm115OAuthCallbackHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
payload := map[string]string{}
for key, values := range c.Request.URL.Query() {
if len(values) > 0 {
payload[key] = values[0]
}
}
if err := c.Request.ParseForm(); err == nil {
for key, values := range c.Request.PostForm {
if len(values) > 0 && payload[key] == "" {
payload[key] = values[0]
}
}
}
if err := svc.Strm.Handle115OAuthCallback(c.Request.Context(), payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// listStrmLocalDirsHandler 本地目录选择器:path 为空时返回根/盘符列表。
func listStrmLocalDirsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
listing, err := svc.Strm.ListStrmLocalDirs(c.Request.Context(), c.Query("path"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, listing)
}
}
+102
View File
@@ -0,0 +1,102 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// TestStrmAdminRoutes 冒烟测试 STRM 管理端点注册与 401 拦截。
func TestStrmAdminRoutesAreRegistered(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
Register(router, &config.Config{
Secrets: config.SecretsConfig{JWTSecret: "test-secret"},
}, zap.NewNop(), &service.Container{Log: zap.NewNop()})
routes := map[string]bool{}
for _, route := range router.Routes() {
routes[route.Method+" "+route.Path] = true
}
for _, want := range []string{
"GET /api/admin/strm/accounts",
"POST /api/admin/strm/accounts",
"PUT /api/admin/strm/accounts/:id",
"DELETE /api/admin/strm/accounts/:id",
"POST /api/admin/strm/accounts/:id/test",
"GET /api/admin/strm/accounts/:id/list",
"POST /api/admin/strm/accounts/115-qr/start",
"POST /api/admin/strm/accounts/115-qr/poll",
"GET /api/admin/strm/settings",
"PUT /api/admin/strm/settings",
"GET /api/admin/strm/paths",
"POST /api/admin/strm/paths",
"PUT /api/admin/strm/paths/:id",
"DELETE /api/admin/strm/paths/:id",
"POST /api/admin/strm/paths/:id/sync",
"POST /api/admin/strm/paths/:id/cancel",
"GET /api/admin/strm/records",
"GET /api/admin/strm/downloads",
"POST /api/admin/strm/downloads/:id/cancel",
"POST /api/admin/strm/downloads/:id/retry",
"GET /api/admin/strm/uploads",
"POST /api/admin/strm/uploads/:id/cancel",
"POST /api/admin/strm/uploads/:id/retry",
"GET /api/strm/play/:provider/:file",
} {
if !routes[want] {
t.Fatalf("%s route is not registered", want)
}
}
}
// TestStrmAccountsCRUD 用内存库走一遍账号/设置/同步目录接口。
func TestStrmAccountsCRUD(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:strm_handler?mode=memory&cache=shared"),
&gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(4)
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.StrmAccount{}, &model.StrmSyncPath{}, &model.StrmSyncRecord{},
&model.StrmDownloadTask{}, &model.StrmUploadTask{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := service.NewWithVersion(&config.Config{}, zap.NewNop(), repos, "test")
router := gin.New()
Register(router, &config.Config{Secrets: config.SecretsConfig{JWTSecret: "test-secret"}}, zap.NewNop(), svc)
// 未登录访问应 401
req := httptest.NewRequest(http.MethodGet, "/api/admin/strm/accounts", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated GET /admin/strm/accounts = %d, want 401", w.Code)
}
// 公开播放端点(本地提供方路径校验失败 → 404/400,不 panic)
req = httptest.NewRequest(http.MethodGet, "/api/strm/play/local/video.mkv?path=%2Ftmp%2Fnope.mkv", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code == http.StatusInternalServerError {
t.Fatalf("strm play endpoint errored: %d", w.Code)
}
}