添加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)
}
}
+5
View File
@@ -51,5 +51,10 @@ func AllModels() []interface{} {
&RegistrationCode{},
&SignIn{},
&UserDevice{},
&StrmAccount{},
&StrmSyncPath{},
&StrmSyncRecord{},
&StrmDownloadTask{},
&StrmUploadTask{},
}
}
+125
View File
@@ -0,0 +1,125 @@
// STRM 管理子系统数据模型。
//
// 参考 QMediaSync 的 STRM 同步设计:网盘账号(account)+ 同步目录(sync path)
// 生成 .strm 文件(内容为指向本服务播放端点的一行 URL),视频元数据文件经
// 下载/上传队列与远端网盘双向同步。
package model
import "time"
// Strm 提供方类型。cloud.Local 常量保持一致。
const (
StrmProvider115 = "cloud115" // 115 网盘(Cookie + 二维码登录)
StrmProviderCloudDrive = "clouddrive2" // CloudDrive2(WebDAV 桥接)
StrmProviderOpenList = "openlist" // OpenList / AList 兼容桥接
StrmProviderLocal = "local" // 本地目录(无账号)
)
// StrmAccount 是一个网盘账号(STRM 同步数据源凭据)。
type StrmAccount struct {
Base
Name string `gorm:"size:128" json:"name"` // 展示名
Provider string `gorm:"size:32;index" json:"provider"` // StrmProvider*
Config string `gorm:"type:text" json:"-"` // JSON:cookie / url / username / password / token
Enabled bool `gorm:"default:true" json:"enabled"` // 是否启用(禁用后同步跳过)
LastTestAt *time.Time `json:"last_test_at"` // 最近一次连通性测试
LastTestResult string `gorm:"size:512" json:"last_test_result"` // ok 或错误信息
LastTestOK bool `json:"last_test_ok"`
}
// StrmSyncPath 是一条 STRM 同步目录配置:把网盘(或本地)某目录下的视频生成
// .strm 文件写到 LocalPath,元数据按需下载/上传。
type StrmSyncPath struct {
Base
Name string `gorm:"size:128" json:"name"` // 展示名
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID;local 为空
Provider string `gorm:"size:32" json:"provider"` // StrmProvider*(冗余,便于列表展示)
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
// STRM 链接配置(空值继承全局 strm.* 设置)
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext
ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过
MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM
AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
Enabled bool `gorm:"default:true" json:"enabled"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastSyncStatus string `gorm:"size:16" json:"last_sync_status"` // idle/running/ok/error/canceled
LastSyncMessage string `gorm:"size:1024" json:"last_sync_message"`
}
// StrmSyncRecord 是一次同步执行的记录。
const (
StrmSyncRecordPending = "pending"
StrmSyncRecordRunning = "running"
StrmSyncRecordDone = "done"
StrmSyncRecordFailed = "failed"
StrmSyncRecordCanceled = "canceled"
)
type StrmSyncRecord struct {
Base
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"`
Status string `gorm:"size:16;index" json:"status"`
Total int64 `json:"total"` // 远端发现的文件总数
NewStrm int64 `json:"new_strm"` // 本次新建/更新的 strm 数
NewMeta int64 `json:"new_meta"` // 本次入队的元数据下载数
Uploaded int64 `json:"uploaded"` // 本次入队的上传数
Pruned int64 `json:"pruned"` // 本次清理的本地多余文件数
Skipped int64 `json:"skipped"` // 内容未变化跳过的 strm 数
Message string `gorm:"size:1024" json:"message"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
// STRM 任务状态。
const (
StrmTaskPending = "pending"
StrmTaskRunning = "running"
StrmTaskDone = "done"
StrmTaskFailed = "failed"
StrmTaskCanceled = "canceled"
)
// StrmDownloadTask 是 strm 元数据下载队列任务(远端网盘 → 本地)。
type StrmDownloadTask struct {
Base
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"`
AccountID string `gorm:"size:36;index" json:"account_id"`
Provider string `gorm:"size:32" json:"provider"`
FileName string `gorm:"size:512" json:"file_name"`
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 远端文件引用(pickcode / 路径)
RemoteDir string `gorm:"size:1024" json:"remote_dir"`
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地目标文件
Size int64 `json:"size"`
Status string `gorm:"size:16;index" json:"status"`
Error string `gorm:"size:1024" json:"error"`
RetryCount int `json:"retry_count"`
NextTryAt *time.Time `json:"next_try_at"` // 失败重试退避;空或已过则可被认领
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
// StrmUploadTask 是 strm 元数据上传队列任务(本地 → 远端网盘)。
type StrmUploadTask struct {
Base
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"`
AccountID string `gorm:"size:36;index" json:"account_id"`
Provider string `gorm:"size:32" json:"provider"`
FileName string `gorm:"size:512" json:"file_name"`
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径
Size int64 `json:"size"`
Status string `gorm:"size:16;index" json:"status"`
Error string `gorm:"size:1024" json:"error"`
RetryCount int `json:"retry_count"`
NextTryAt *time.Time `json:"next_try_at"` // 失败重试退避;空或已过则可被认领
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
+10
View File
@@ -26,6 +26,11 @@ type Container struct {
RegCode *RegistrationCodeRepository
SignIn *SignInRepository
UserDevice *UserDeviceRepository
StrmAccount *StrmAccountRepository
StrmSyncPath *StrmSyncPathRepository
StrmSyncRecord *StrmSyncRecordRepository
StrmDownload *StrmDownloadTaskRepository
StrmUpload *StrmUploadTaskRepository
}
// New 将每个 repository 连接到单个 *gorm.DB。
@@ -48,5 +53,10 @@ func New(db *gorm.DB) *Container {
RegCode: &RegistrationCodeRepository{db: db},
SignIn: &SignInRepository{db: db},
UserDevice: &UserDeviceRepository{db: db},
StrmAccount: &StrmAccountRepository{db: db},
StrmSyncPath: &StrmSyncPathRepository{db: db},
StrmSyncRecord: &StrmSyncRecordRepository{db: db},
StrmDownload: &StrmDownloadTaskRepository{db: db},
StrmUpload: &StrmUploadTaskRepository{db: db},
}
}
+438
View File
@@ -0,0 +1,438 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// ─── StrmAccount ───────────────────────────────────────────────────────────────
// StrmAccountRepository persists model.StrmAccount.
type StrmAccountRepository struct{ db *gorm.DB }
func (r *StrmAccountRepository) Create(ctx context.Context, a *model.StrmAccount) error {
return r.db.WithContext(ctx).Create(a).Error
}
func (r *StrmAccountRepository) FindByID(ctx context.Context, id string) (*model.StrmAccount, error) {
var a model.StrmAccount
err := r.db.WithContext(ctx).Where("id = ?", id).First(&a).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &a, nil
}
func (r *StrmAccountRepository) List(ctx context.Context) ([]model.StrmAccount, error) {
var rows []model.StrmAccount
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
return rows, err
}
func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount) error {
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", a.ID).Updates(map[string]any{
"name": a.Name,
"provider": a.Provider,
"config": a.Config,
"enabled": a.Enabled,
"last_test_at": a.LastTestAt,
"last_test_result": a.LastTestResult,
"last_test_ok": a.LastTestOK,
"updated_at": time.Now(),
}).Error
}
func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmAccount{}).Error
}
// ─── StrmSyncPath ──────────────────────────────────────────────────────────────
// StrmSyncPathRepository persists model.StrmSyncPath.
type StrmSyncPathRepository struct{ db *gorm.DB }
func (r *StrmSyncPathRepository) Create(ctx context.Context, p *model.StrmSyncPath) error {
return r.db.WithContext(ctx).Create(p).Error
}
func (r *StrmSyncPathRepository) FindByID(ctx context.Context, id string) (*model.StrmSyncPath, error) {
var p model.StrmSyncPath
err := r.db.WithContext(ctx).Where("id = ?", id).First(&p).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
func (r *StrmSyncPathRepository) List(ctx context.Context) ([]model.StrmSyncPath, error) {
var rows []model.StrmSyncPath
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
return rows, err
}
func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPath) error {
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
"name": p.Name,
"account_id": p.AccountID,
"provider": p.Provider,
"remote_path": p.RemotePath,
"local_path": p.LocalPath,
"strm_base_url": p.StrmBaseURL,
"video_ext": p.VideoExt,
"meta_ext": p.MetaExt,
"exclude_name": p.ExcludeName,
"min_video_size_mb": p.MinVideoSizeMB,
"add_path": p.AddPath,
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"enabled": p.Enabled,
"last_sync_at": p.LastSyncAt,
"last_sync_status": p.LastSyncStatus,
"last_sync_message": p.LastSyncMessage,
"updated_at": time.Now(),
}).Error
}
func (r *StrmSyncPathRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmSyncPath{}).Error
}
// ─── StrmSyncRecord ────────────────────────────────────────────────────────────
// StrmSyncRecordRepository persists model.StrmSyncRecord.
type StrmSyncRecordRepository struct{ db *gorm.DB }
func (r *StrmSyncRecordRepository) Create(ctx context.Context, rec *model.StrmSyncRecord) error {
return r.db.WithContext(ctx).Create(rec).Error
}
func (r *StrmSyncRecordRepository) Update(ctx context.Context, rec *model.StrmSyncRecord) error {
return r.db.WithContext(ctx).Model(&model.StrmSyncRecord{}).Where("id = ?", rec.ID).Updates(map[string]any{
"status": rec.Status,
"total": rec.Total,
"new_strm": rec.NewStrm,
"new_meta": rec.NewMeta,
"uploaded": rec.Uploaded,
"pruned": rec.Pruned,
"skipped": rec.Skipped,
"message": rec.Message,
"started_at": rec.StartedAt,
"finished_at": rec.FinishedAt,
"updated_at": time.Now(),
}).Error
}
func (r *StrmSyncRecordRepository) List(ctx context.Context, syncPathID string, limit int) ([]model.StrmSyncRecord, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
var rows []model.StrmSyncRecord
q := r.db.WithContext(ctx)
if syncPathID != "" {
q = q.Where("sync_path_id = ?", syncPathID)
}
err := q.Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
// ─── StrmDownloadTask ──────────────────────────────────────────────────────────
// StrmDownloadTaskRepository persists model.StrmDownloadTask.
type StrmDownloadTaskRepository struct{ db *gorm.DB }
func (r *StrmDownloadTaskRepository) Create(ctx context.Context, t *model.StrmDownloadTask) error {
return r.db.WithContext(ctx).Create(t).Error
}
func (r *StrmDownloadTaskRepository) FindByID(ctx context.Context, id string) (*model.StrmDownloadTask, error) {
var t model.StrmDownloadTask
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
func (r *StrmDownloadTaskRepository) List(ctx context.Context, status string, page, pageSize int) ([]model.StrmDownloadTask, int64, error) {
page, pageSize = normalizeTaskPage(page, pageSize)
var total int64
if err := taskStatusScope(r.db.WithContext(ctx), status).Model(&model.StrmDownloadTask{}).Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.StrmDownloadTask
err := taskStatusScope(r.db.WithContext(ctx), status).
Order("created_at desc, id desc").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&rows).Error
return rows, total, err
}
func (r *StrmDownloadTaskRepository) CountByStatus(ctx context.Context) (map[string]int64, error) {
var rows []struct {
Status string
Count int64
}
err := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Select("status, count(*) as count").
Group("status").Scan(&rows).Error
if err != nil {
return nil, err
}
out := map[string]int64{}
for _, row := range rows {
out[row.Status] = row.Count
}
return out, nil
}
// ClaimPendingDownload picks the oldest pending task and marks it running.
// Returns (nil, nil) when the queue is empty.
func (r *StrmDownloadTaskRepository) ClaimPendingDownload(ctx context.Context, limit int) ([]model.StrmDownloadTask, error) {
var rows []model.StrmDownloadTask
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmDownloadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
})
if err != nil {
return nil, err
}
return rows, nil
}
func (r *StrmDownloadTaskRepository) Update(ctx context.Context, t *model.StrmDownloadTask) error {
return r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
}
func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error
}
// ClearDone 清空全部已完成下载任务。
func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Where("status = ?", model.StrmTaskDone).Delete(&model.StrmDownloadTask{})
return res.RowsAffected, res.Error
}
// ClearFinished 清空全部已完成与失败下载任务。
func (r *StrmDownloadTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed}).
Delete(&model.StrmDownloadTask{})
return res.RowsAffected, res.Error
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmDownloadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskFailed).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
return res.RowsAffected, res.Error
}
// CancelPending 批量取消所有排队中的任务。
func (r *StrmDownloadTaskRepository) CancelPending(ctx context.Context) (int64, error) {
now := time.Now()
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskPending).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
return res.RowsAffected, res.Error
}
// CountActive 统计某同步目录下目标仍在排队/进行的任务数(用于去重)。
func (r *StrmDownloadTaskRepository) CountActive(ctx context.Context, syncPathID, localPath string) int64 {
var count int64
r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("sync_path_id = ? AND local_path = ? AND status IN ?",
syncPathID, localPath, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Count(&count)
return count
}
func (r *StrmDownloadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmDownloadTask{}).Error
}
// ─── StrmUploadTask ────────────────────────────────────────────────────────────
// StrmUploadTaskRepository persists model.StrmUploadTask.
type StrmUploadTaskRepository struct{ db *gorm.DB }
func (r *StrmUploadTaskRepository) Create(ctx context.Context, t *model.StrmUploadTask) error {
return r.db.WithContext(ctx).Create(t).Error
}
func (r *StrmUploadTaskRepository) FindByID(ctx context.Context, id string) (*model.StrmUploadTask, error) {
var t model.StrmUploadTask
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
func (r *StrmUploadTaskRepository) List(ctx context.Context, status string, page, pageSize int) ([]model.StrmUploadTask, int64, error) {
page, pageSize = normalizeTaskPage(page, pageSize)
var total int64
if err := taskStatusScope(r.db.WithContext(ctx), status).Model(&model.StrmUploadTask{}).Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.StrmUploadTask
err := taskStatusScope(r.db.WithContext(ctx), status).
Order("created_at desc, id desc").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&rows).Error
return rows, total, err
}
// normalizeTaskPage 钳制分页参数:页码至少 1,单页大小 1..200。
func normalizeTaskPage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}
return page, pageSize
}
// taskStatusScope 按状态过滤(空状态表示不过滤)。
func taskStatusScope(db *gorm.DB, status string) *gorm.DB {
if status != "" {
return db.Where("status = ?", status)
}
return db
}
func (r *StrmUploadTaskRepository) CountByStatus(ctx context.Context) (map[string]int64, error) {
var rows []struct {
Status string
Count int64
}
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Select("status, count(*) as count").
Group("status").Scan(&rows).Error
if err != nil {
return nil, err
}
out := map[string]int64{}
for _, row := range rows {
out[row.Status] = row.Count
}
return out, nil
}
// ClaimPendingUpload picks the oldest pending task and marks it running.
// Returns (nil, nil) when the queue is empty.
func (r *StrmUploadTaskRepository) ClaimPendingUpload(ctx context.Context, limit int) ([]model.StrmUploadTask, error) {
var rows []model.StrmUploadTask
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmUploadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
})
if err != nil {
return nil, err
}
return rows, nil
}
func (r *StrmUploadTaskRepository) Update(ctx context.Context, t *model.StrmUploadTask) error {
return r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
}
func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error
}
// CountActive 统计某同步目录下目标仍在排队/进行的任务数(用于去重)。
func (r *StrmUploadTaskRepository) CountActive(ctx context.Context, syncPathID, localPath string) int64 {
var count int64
r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("sync_path_id = ? AND local_path = ? AND status IN ?",
syncPathID, localPath, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Count(&count)
return count
}
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmUploadTask{}).Error
}
+138
View File
@@ -0,0 +1,138 @@
// Package cloud implements pluggable cloud-disk (网盘) providers used by the
// external-storage subsystem to expose remote files as playable media via
// HTTP 302 redirects.
//
// The design offloads playback to the cloud provider: instead of the
// host downloading and re-streaming bytes, a provider resolves a file to a
// short-lived direct download URL and the player is 302-redirected straight to
// the cloud CDN. The host only performs a tiny redirect, freeing its CPU and
// bandwidth.
//
// Each provider authenticates with a cookie (obtained via the web UI, an API
// cookie, or a QR-code login flow). Providers are intentionally side-effect
// free and take an *http.Client so they can be exercised against httptest
// mock servers in unit tests.
package cloud
import (
"context"
"errors"
"net/http"
"strings"
"time"
)
// timeNow is a seam so tests can pin timestamps.
var timeNow = time.Now
// Provider types recognised by the registry.
const (
Type115 = "cloud115" // 115 网盘
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
TypeOpenList = "openlist" // OpenList / AList-compatible bridge
)
// ErrUnsupported is returned for an unknown provider type.
var ErrUnsupported = errors.New("unsupported cloud provider")
// FileEntry is one item in a cloud directory listing.
type FileEntry struct {
ID string `json:"id"` // provider-native file id
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
// PickCode is 115-specific; other providers use ID directly.
PickCode string `json:"pick_code,omitempty"`
}
// DirectLink is a resolved playback target.
type DirectLink struct {
URL string `json:"url"`
// Headers that must accompany a request to URL (e.g. User-Agent, Cookie).
Headers map[string]string `json:"-"`
// Proxy reports whether URL requires the host to reverse-proxy the bytes
// (because the headers cannot be carried by a plain browser 302). When
// false the play handler issues a pure 302 redirect (true offload).
Proxy bool `json:"-"`
}
// Provider is the common cloud-disk interface.
type Provider interface {
// Type returns the provider key.
Type() string
// Ping validates the stored credentials (cookie). Cheap, used by the
// storage-config Test() probe.
Ping(ctx context.Context) error
// List returns the entries under dirID. An empty dirID means the root.
List(ctx context.Context, dirID string) ([]FileEntry, error)
// Resolve turns a provider-native file reference (id or pickcode) into a
// short-lived direct download link suitable for 302 playback.
Resolve(ctx context.Context, fileRef string) (*DirectLink, error)
}
// MutableProvider is implemented by cloud bridges that support safe folder
// management through their official API or standard WebDAV methods.
type MutableProvider interface {
Provider
Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error)
Rename(ctx context.Context, ref, name string) (*FileEntry, error)
}
// MovableProvider is implemented by writable cloud bridges that can move an
// entry across directories, optionally renaming it in the same operation.
type MovableProvider interface {
MutableProvider
Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error)
}
// New constructs a provider of the given type from a free-form config map
// (as persisted by StorageConfigService). The client is shared so callers can
// inject timeouts / test transports.
func New(typ string, cfg map[string]any, client *http.Client) (Provider, error) {
if client == nil {
client = http.DefaultClient
}
switch typ {
case Type115:
// 115 使用开放平台(openapi)驱动(QMediaSync 同款:授权/列目录/直链)。
return NewOpenAPI115(str(cfg["app_id"]), str(cfg["access_token"]), str(cfg["refresh_token"])), nil
case TypeCloudDrive2:
return newCloudDrive2(cfg, client), nil
case TypeOpenList:
return newOpenList(cfg, client), nil
default:
return nil, ErrUnsupported
}
}
// IsCloudType reports whether typ is a cloud-disk provider.
func IsCloudType(typ string) bool {
return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList
}
// str coerces a config value to a trimmed string.
func str(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
// boolish coerces a config value to bool ("true"/"1"/true → true).
func boolish(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
s := strings.ToLower(strings.TrimSpace(t))
return s == "1" || s == "true" || s == "yes" || s == "on"
default:
return false
}
}
// defaultUA is a desktop browser UA accepted by upstream cloud providers.
const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
+134
View File
@@ -0,0 +1,134 @@
package cloud
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
)
// 115 开放平台(openapi)驱动测试:mock proapi 的列目录/直链接口。
func newOpenAPI115TestProvider(t *testing.T, handler http.HandlerFunc) (*openAPI115Provider, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
old := cloud115.ProAPIBase
cloud115.ProAPIBase = srv.URL
t.Cleanup(func() { cloud115.ProAPIBase = old; srv.Close() })
p := NewOpenAPI115("100195125", "token-abc", "refresh-xyz")
return p, srv
}
func Test115OpenAPIListAndResolve(t *testing.T) {
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/ufile/files":
if r.Header.Get("Authorization") != "Bearer token-abc" {
t.Errorf("missing authorization header")
}
if r.URL.Query().Get("cid") != "0" {
t.Errorf("bad cid %q", r.URL.Query().Get("cid"))
}
w.Write([]byte(`{"state":true,"data":[
{"fid":"100","cid":"100","fc":"0","fn":"Movies","fs":0,"pc":""},
{"fid":"200","fc":"1","fn":"Inception.mkv","fs":456,"pc":"pick200"}]}`))
case "/open/ufile/downurl":
if r.Header.Get("Authorization") != "Bearer token-abc" {
t.Errorf("missing authorization header")
}
_ = r.ParseForm()
if r.PostFormValue("pick_code") != "pick200" {
t.Errorf("bad pick_code %q", r.PostFormValue("pick_code"))
}
w.Write([]byte(`{"state":true,"data":{"200":{"file_name":"Inception.mkv","url":{"url":"https://cdn.115/x.mkv?t=1"}}}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
entries, err := p.List(context.Background(), "0")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 2 {
t.Fatalf("want 2 entries: %#v", entries)
}
if !entries[0].IsDir || entries[0].ID != "100" || entries[0].Name != "Movies" {
t.Fatalf("dir entry wrong: %#v", entries[0])
}
if entries[1].IsDir || entries[1].PickCode != "pick200" || entries[1].Size != 456 {
t.Fatalf("file entry wrong: %#v", entries[1])
}
link, err := p.Resolve(context.Background(), "pick200")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != "https://cdn.115/x.mkv?t=1" {
t.Fatalf("bad url: %s", link.URL)
}
if link.Proxy {
t.Fatalf("115 openapi should default to 302 (no proxy)")
}
}
func Test115OpenAPIListPaginates(t *testing.T) {
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/files" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
count := 100
if offset > 0 {
count = 1
}
items := make([]string, 0, count)
for i := 0; i < count; i++ {
n := offset + i
items = append(items, `{"fid":"`+strconv.Itoa(n)+`","fc":"1","fn":"Movie.`+padZero(n)+`.mkv","fs":1,"pc":"pick`+strconv.Itoa(n)+`"}`)
}
w.Write([]byte(`{"state":true,"data":[` + strings.Join(items, ",") + `]}`))
})
entries, err := p.List(context.Background(), "0")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 101 {
t.Fatalf("entries = %d, want 101", len(entries))
}
if entries[100].ID != "100" || entries[100].PickCode != "pick100" {
t.Fatalf("last entry wrong: %#v", entries[100])
}
}
func Test115OpenAPIErrorSurfaced(t *testing.T) {
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/downurl" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
w.Write([]byte(`{"state":false,"message":"文件不存在"}`))
})
_, err := p.Resolve(context.Background(), "pickX")
if err == nil || !strings.Contains(err.Error(), "文件不存在") {
t.Fatalf("want upstream error surfaced, got %v", err)
}
}
func Test115OpenAPIPingRequiresToken(t *testing.T) {
p := NewOpenAPI115("100195125", "", "")
if err := p.Ping(context.Background()); err == nil {
t.Fatalf("ping without token should fail")
}
}
func padZero(n int) string {
s := strconv.Itoa(n)
for len(s) < 3 {
s = "0" + s
}
return s
}
+25
View File
@@ -0,0 +1,25 @@
package cloud
import (
"net/http"
"testing"
)
func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
cd2 := newCloudDrive2(map[string]any{"url": "http://example.test/dav", "force_302": "true"}, http.DefaultClient)
if !cd2.proxy {
t.Fatalf("clouddrive2 should keep safe proxy mode; force_302 is deprecated")
}
}
func TestUnsupportedProvider(t *testing.T) {
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
t.Fatalf("want ErrUnsupported, got %v", err)
}
if _, err := New("quark", nil, nil); err != ErrUnsupported {
t.Fatalf("quark should be unsupported, got %v", err)
}
if IsCloudType("quark") {
t.Fatal("quark should not be an active cloud provider")
}
}
+237
View File
@@ -0,0 +1,237 @@
package cloud
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"path"
"strings"
)
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
//
// CloudDrive2 integrates many cloud disks (115 / 123 / Aliyun and more).
// Treating it as a WebDAV-backed cloud provider lets MediaStationGo
// browse, mount and upload to those disks without carrying every provider's
// private chunk-upload protocol in this project.
type cloudDrive2Provider struct {
typ string
name string
base *url.URL
username string
password string
token string
ua string
apiBase *url.URL
client *http.Client
proxy bool
}
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
return newCloudDAVProvider(TypeCloudDrive2, "clouddrive2", cfg, client, "/dav")
}
func newOpenList(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
return newCloudDAVProvider(TypeOpenList, "openlist", cfg, client, "/dav")
}
func newCloudDAVProvider(typ, name string, cfg map[string]any, client *http.Client, defaultDAVPath string) *cloudDrive2Provider {
rawURL := webDAVURLFromConfig(cfg, defaultDAVPath)
u, _ := url.Parse(strings.TrimRight(rawURL, "/"))
var apiBase *url.URL
if typ == TypeOpenList {
apiBase = openListAPIBaseFromConfig(cfg, rawURL, defaultDAVPath)
}
ua := str(cfg["ua"])
if ua == "" {
ua = defaultUA
}
proxy := true
return &cloudDrive2Provider{
typ: typ,
name: name,
base: u,
username: str(cfg["username"]),
password: str(cfg["password"]),
token: str(cfg["token"]),
ua: ua,
apiBase: apiBase,
client: client,
proxy: proxy,
}
}
func (p *cloudDrive2Provider) Type() string { return p.typ }
func (p *cloudDrive2Provider) Ping(ctx context.Context) error {
_, err := p.List(ctx, "")
return err
}
func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
if err := p.validate(); err != nil {
return nil, err
}
ref := normalizeCloudDAVPath(fileRef)
if ref == "/" {
return nil, fmt.Errorf("%s: file reference required", p.name)
}
if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) {
if p.apiBase == nil {
return nil, fmt.Errorf("%s: pure 302 playback requires an OpenList API server address; configure server/api_url so /api/fs/get can return raw_url", p.name)
}
link, err := p.resolveOpenListAPIDirect(ctx, ref)
if err != nil {
return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err)
}
return link, nil
}
if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) {
link, err := p.resolveCloudDAVRedirectDirect(ctx, ref)
if err != nil {
return nil, fmt.Errorf("%s: pure 302 playback requires CloudDrive2/WebDAV to return a CDN Location for %s: %w", p.name, ref, err)
}
return link, nil
}
headers := map[string]string{
"User-Agent": p.ua,
}
if p.token != "" {
headers["Authorization"] = p.token
} else if p.username != "" {
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password))
}
return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil
}
func (p *cloudDrive2Provider) validate() error {
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
return fmt.Errorf("%s: missing WebDAV URL", p.name)
}
return nil
}
func webDAVURLFromConfig(cfg map[string]any, defaultDAVPath string) string {
rawURL := str(cfg["url"])
if rawURL == "" {
rawURL = str(cfg["webdav_url"])
}
if rawURL != "" {
return ensureDefaultDAVPath(rawURL, defaultDAVPath)
}
return defaultWebDAVURL(str(cfg["server"]), defaultDAVPath)
}
func defaultWebDAVURL(server, defaultDAVPath string) string {
server = strings.TrimRight(strings.TrimSpace(server), "/")
if server == "" {
return ""
}
davPath := strings.TrimSpace(defaultDAVPath)
if davPath == "" {
return server
}
if !strings.HasPrefix(davPath, "/") {
davPath = "/" + davPath
}
return server + davPath
}
func openListAPIBaseFromConfig(cfg map[string]any, webDAVURL, defaultDAVPath string) *url.URL {
raw := str(cfg["server"])
if raw == "" {
raw = firstNonEmpty(str(cfg["api_url"]), webDAVURL)
}
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return nil
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil
}
davPath := strings.Trim(strings.TrimSpace(defaultDAVPath), "/")
if davPath != "" {
pathParts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
if len(pathParts) > 0 && strings.EqualFold(pathParts[len(pathParts)-1], davPath) {
u.Path = strings.Join(pathParts[:len(pathParts)-1], "/")
if u.Path == "" {
u.Path = "/"
}
}
}
u.RawPath = ""
u.RawQuery = ""
u.Fragment = ""
return u
}
func (p *cloudDrive2Provider) openListAPIURL(apiPath string) string {
if p.apiBase == nil {
return ""
}
u := *p.apiBase
u.RawPath = ""
basePath := strings.TrimRight(u.Path, "/")
apiPath = "/" + strings.TrimLeft(apiPath, "/")
if basePath == "" || basePath == "/" {
u.Path = apiPath
} else {
u.Path = basePath + apiPath
}
return u.String()
}
func ensureDefaultDAVPath(rawURL, defaultDAVPath string) string {
rawURL = strings.TrimRight(strings.TrimSpace(rawURL), "/")
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return rawURL
}
if strings.TrimSpace(defaultDAVPath) == "" {
return rawURL
}
if u.Path == "" || u.Path == "/" {
davPath := strings.TrimSpace(defaultDAVPath)
if !strings.HasPrefix(davPath, "/") {
davPath = "/" + davPath
}
u.Path = davPath
u.RawPath = ""
return strings.TrimRight(u.String(), "/")
}
return rawURL
}
func normalizeCloudDAVPath(p string) string {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p == "" || p == "." {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
cleaned := path.Clean(p)
if cleaned == "." {
return "/"
}
return cleaned
}
func sameCloudDAVPath(a, b string) bool {
return strings.TrimRight(normalizeCloudDAVPath(a), "/") == strings.TrimRight(normalizeCloudDAVPath(b), "/")
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+172
View File
@@ -0,0 +1,172 @@
package cloud
import (
"context"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
)
func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry, error) {
if err := p.validate(); err != nil {
return nil, err
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
return p.listOpenListAPI(ctx, dir)
}
target := normalizeCloudDAVPath(dir)
req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody))
if err != nil {
return nil, err
}
p.auth(req)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Accept", "application/xml,text/xml,*/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.decorateDAVStatusError(resp, target)
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
var multi cloudDAVMultiStatus
if err := xml.Unmarshal(body, &multi); err != nil {
return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err)
}
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
currentID := normalizeCloudDAVPath(target)
out := make([]FileEntry, 0, len(multi.Responses))
for _, item := range multi.Responses {
entryPath, err := p.entryIDFromHref(item.Href, basePath)
if err != nil || entryPath == "" || sameCloudDAVPath(entryPath, currentID) {
continue
}
name := firstNonEmpty(item.PropStat.Prop.DisplayName, path.Base(strings.TrimRight(entryPath, "/")))
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: entryPath,
Name: name,
IsDir: item.PropStat.Prop.ResourceType.Collection != nil || strings.HasSuffix(item.Href, "/"),
Size: parseDAVSize(item.PropStat.Prop.ContentLength),
})
}
return out, nil
}
func (p *cloudDrive2Provider) resolveCloudDAVRedirectDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
target := p.urlFor(fileRef)
headers := map[string]string{
"User-Agent": p.ua,
}
if p.token != "" {
headers["Authorization"] = p.token
} else if p.username != "" {
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password))
}
location, status, err := p.firstHTTPRedirectLocation(ctx, target, headers)
if err != nil {
return nil, decorateDAVTransportError(p.name, target, err)
}
if location == "" {
return nil, fmt.Errorf("%s: WebDAV %s returned http %d without CDN Location; refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, status)
}
return &DirectLink{URL: location, Headers: nil, Proxy: false}, nil
}
func (p *cloudDrive2Provider) firstHTTPRedirectLocation(ctx context.Context, target string, headers map[string]string) (string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Range", "bytes=0-0")
if strings.TrimSpace(p.ua) != "" {
req.Header.Set("User-Agent", p.ua)
}
for key, value := range headers {
key = strings.TrimSpace(key)
if key != "" && strings.TrimSpace(value) != "" {
req.Header.Set(key, value)
}
}
client := p.client
if client == nil {
client = http.DefaultClient
}
noFollow := *client
noFollow.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := noFollow.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
status := resp.StatusCode
if status >= 300 && status < 400 {
rawLocation := strings.TrimSpace(resp.Header.Get("Location"))
if rawLocation == "" {
return "", status, fmt.Errorf("%s: upstream returned redirect http %d without Location", p.name, status)
}
location, err := resolveHTTPRedirectLocation(target, rawLocation)
if err != nil {
return "", status, err
}
return location, status, nil
}
return "", status, nil
}
func resolveHTTPRedirectLocation(baseURL, rawLocation string) (string, error) {
rawLocation = strings.TrimSpace(rawLocation)
if rawLocation == "" {
return "", fmt.Errorf("empty redirect Location")
}
if strings.HasPrefix(rawLocation, "//") {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" {
return "", fmt.Errorf("protocol-relative redirect Location without base scheme")
}
rawLocation = base.Scheme + ":" + rawLocation
}
location, err := url.Parse(rawLocation)
if err != nil {
return "", fmt.Errorf("invalid redirect Location: %w", err)
}
if location.IsAbs() {
if location.Scheme != "http" && location.Scheme != "https" {
return "", fmt.Errorf("unsupported redirect Location scheme %q", location.Scheme)
}
return location.String(), nil
}
base, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid redirect base URL: %w", err)
}
return base.ResolveReference(location).String(), nil
}
func (p *cloudDrive2Provider) auth(req *http.Request) {
req.Header.Set("User-Agent", p.ua)
if p.token != "" {
req.Header.Set("Authorization", p.token)
return
}
if p.username != "" {
req.SetBasicAuth(p.username, p.password)
}
}
@@ -0,0 +1,55 @@
package cloud
import (
"fmt"
"io"
"net/http"
"strings"
)
func (p *cloudDrive2Provider) decorateDAVStatusError(resp *http.Response, target string) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
detail := compactDAVErrorBody(string(body))
if detail == "" {
if resp.StatusCode == http.StatusMethodNotAllowed {
return fmt.Errorf("%s: list %s returned http %d;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%s: list %s returned http %d;请填写 OpenList/CloudDrive2 的 Token 或用户名密码,或确认 WebDAV 凭据可用", p.name, target, resp.StatusCode)
}
return fmt.Errorf("%s: list %s returned http %d", p.name, target, resp.StatusCode)
}
if resp.StatusCode == http.StatusMethodNotAllowed {
return fmt.Errorf("%s: list %s returned http %d:%s;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode, detail)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%s: list %s returned http %d:%s;请检查 WebDAV 用户名/密码、Authorization Token,或先在 OpenList/CloudDrive2 中保存对应网盘 Cookie", p.name, target, resp.StatusCode, detail)
}
return fmt.Errorf("%s: list %s returned http %d:%s", p.name, target, resp.StatusCode, detail)
}
func compactDAVErrorBody(raw string) string {
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\x00", ""))
if raw == "" {
return ""
}
raw = strings.Join(strings.Fields(raw), " ")
if len([]rune(raw)) > 180 {
return string([]rune(raw)[:180]) + "…"
}
return raw
}
func decorateDAVTransportError(name, target string, err error) error {
if err == nil {
return nil
}
message := err.Error()
if strings.Contains(message, "server gave HTTP response to HTTPS client") {
return fmt.Errorf("%s: %w;当前地址使用 https://,但服务端返回 HTTP。请改用 http:// 地址,例如 OpenList 默认 WebDAV 通常是 http://host:5244/dav/;如果必须使用 https,请在 OpenList 前配置反向代理和证书", name, err)
}
if strings.Contains(message, "first record does not look like a TLS handshake") {
return fmt.Errorf("%s: %w;疑似把 HTTP 服务配置成了 https://,请检查 %s 的协议头", name, err, target)
}
return err
}
@@ -0,0 +1,85 @@
package cloud
import (
"net/url"
"strconv"
"strings"
)
func (p *cloudDrive2Provider) urlFor(remotePath string) string {
u := *p.base
u.RawPath = ""
basePath := strings.TrimRight(u.Path, "/")
remote := strings.Trim(normalizeCloudDAVPath(remotePath), "/")
switch {
case basePath == "" || basePath == "/":
if remote == "" {
u.Path = "/"
} else {
u.Path = "/" + remote
}
case remote == "":
u.Path = basePath
default:
u.Path = basePath + "/" + remote
}
return u.String()
}
func (p *cloudDrive2Provider) entryIDFromHref(href, basePath string) (string, error) {
if href == "" {
return "", nil
}
parsed, err := url.Parse(href)
if err != nil {
return "", err
}
hrefPath := parsed.EscapedPath()
if hrefPath == "" {
hrefPath = href
}
if basePath != "" && basePath != "/" {
hrefPath = strings.TrimPrefix(hrefPath, basePath)
}
if decoded, err := url.PathUnescape(hrefPath); err == nil {
hrefPath = decoded
}
return normalizeCloudDAVPath(hrefPath), nil
}
const cloudDAVPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>`
type cloudDAVMultiStatus struct {
Responses []cloudDAVResponse `xml:"response"`
}
type cloudDAVResponse struct {
Href string `xml:"href"`
PropStat cloudDAVPropStat `xml:"propstat"`
}
type cloudDAVPropStat struct {
Prop cloudDAVProp `xml:"prop"`
}
type cloudDAVProp struct {
DisplayName string `xml:"displayname"`
ContentLength string `xml:"getcontentlength"`
ResourceType cloudDAVResourceType `xml:"resourcetype"`
}
type cloudDAVResourceType struct {
Collection *struct{} `xml:"collection"`
}
func parseDAVSize(raw string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return n
}
@@ -0,0 +1,312 @@
package cloud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"path"
"strings"
)
func (p *cloudDrive2Provider) Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error) {
cleanName, err := cleanCloudEntryName(name)
if err != nil {
return nil, err
}
parent := normalizeCloudDAVPath(parentDir)
target := joinOpenListAPIPath(parent, cleanName)
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIMkdir(ctx, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
if err := p.webDAVMkdir(ctx, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
func (p *cloudDrive2Provider) Rename(ctx context.Context, ref, name string) (*FileEntry, error) {
cleanName, err := cleanCloudEntryName(name)
if err != nil {
return nil, err
}
source := normalizeCloudDAVPath(ref)
if source == "/" {
return nil, fmt.Errorf("%s: cannot rename root directory", p.name)
}
target := joinOpenListAPIPath(path.Dir(source), cleanName)
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIRename(ctx, source, cleanName); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
if err := p.webDAVRename(ctx, source, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
func (p *cloudDrive2Provider) Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error) {
source := normalizeCloudDAVPath(ref)
if source == "/" {
return nil, fmt.Errorf("%s: cannot move root directory", p.name)
}
cleanName := strings.TrimSpace(name)
if cleanName == "" {
cleanName = path.Base(source)
}
var err error
cleanName, err = cleanCloudEntryName(cleanName)
if err != nil {
return nil, err
}
targetDir = normalizeCloudDAVPath(targetDir)
target := joinOpenListAPIPath(targetDir, cleanName)
if sameCloudDAVPath(source, target) {
return &FileEntry{ID: target, Name: cleanName}, nil
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIMove(ctx, source, targetDir, cleanName); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName}, nil
}
if err := p.webDAVRename(ctx, source, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName}, nil
}
func cleanCloudEntryName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "", fmt.Errorf("entry name is required")
}
if strings.ContainsAny(name, `/\`) {
return "", fmt.Errorf("entry name cannot contain path separators")
}
return name, nil
}
func (p *cloudDrive2Provider) openListAPIMkdir(ctx context.Context, target string) error {
return p.openListAPIPost(ctx, "/api/fs/mkdir", map[string]string{"path": normalizeCloudDAVPath(target)}, "mkdir")
}
func (p *cloudDrive2Provider) openListAPIRename(ctx context.Context, source, name string) error {
return p.openListAPIPost(ctx, "/api/fs/rename", map[string]string{
"path": normalizeCloudDAVPath(source),
"name": name,
}, "rename")
}
func (p *cloudDrive2Provider) openListAPIMove(ctx context.Context, source, targetDir, targetName string) error {
targetDir = normalizeCloudDAVPath(targetDir)
sourceName := path.Base(normalizeCloudDAVPath(source))
if sameCloudDAVPath(path.Dir(source), targetDir) {
if sourceName == targetName {
return nil
}
return p.openListAPIRename(ctx, source, targetName)
}
if err := p.openListAPIPost(ctx, "/api/fs/move", map[string]any{
"src_dir": normalizeCloudDAVPath(path.Dir(source)),
"dst_dir": targetDir,
"names": []string{sourceName},
}, "move"); err != nil {
return err
}
if sourceName != targetName {
moved := joinOpenListAPIPath(targetDir, sourceName)
return p.openListAPIRename(ctx, moved, targetName)
}
return nil
}
func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath string, payload any, action string) error {
token, err := p.openListAPIToken(ctx)
if err != nil {
return err
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL(apiPath), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.openListAPIURL(apiPath), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s: api %s returned http %d", p.name, action, resp.StatusCode)
}
var decoded struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return fmt.Errorf("%s: decode api %s: %w", p.name, action, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return fmt.Errorf("%s: api %s failed: %s", p.name, action, msg)
}
return nil
}
// PutFile 把本地文件内容上传(覆盖)到远端 remotePath。
// OpenList 账号优先走 OpenList /api/fs/form 分片表单接口;其余走 WebDAV PUT。
func (p *cloudDrive2Provider) PutFile(ctx context.Context, remotePath string, r io.Reader) error {
target := normalizeCloudDAVPath(remotePath)
if target == "/" {
return fmt.Errorf("%s: cannot upload to root directory", p.name)
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
return p.openListAPIPutFile(ctx, target, r)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.urlFor(target), r)
if err != nil {
return err
}
p.auth(req)
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK, http.StatusNoContent:
return nil
default:
return p.decorateDAVMutationStatusError(resp, "upload", target)
}
}
// openListAPIPutFile 通过 OpenList /api/fs/form 上传(QMediaSync 同款契约:
// PUT + multipart + File-Path 头)。
func (p *cloudDrive2Provider) openListAPIPutFile(ctx context.Context, remotePath string, r io.Reader) error {
token, err := p.openListAPIToken(ctx)
if err != nil {
return err
}
encodedPath := openListPathEscape(remotePath)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
formFile, err := writer.CreateFormFile("file", path.Base(remotePath))
if err != nil {
return err
}
if _, err := io.Copy(formFile, r); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.openListAPIURL("/api/fs/form"), body)
if err != nil {
return err
}
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("File-Path", encodedPath)
req.Header.Set("As-Task", "true")
req.Header.Set("Overwrite", "true")
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/form"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return p.openListAPIStatusError("upload", remotePath, resp.StatusCode)
}
// OpenList 返回 code=200 即任务受理成功(小文件同步完成,大文件异步排队)。
return nil
}
// openListPathEscape 保留斜杠地 URL 编码远端路径(OpenList File-Path 需要)。
func openListPathEscape(p string) string {
parts := strings.Split(strings.TrimPrefix(normalizeCloudDAVPath(p), "/"), "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func (p *cloudDrive2Provider) webDAVMkdir(ctx context.Context, target string) error {
req, err := http.NewRequestWithContext(ctx, "MKCOL", p.urlFor(target), nil)
if err != nil {
return err
}
p.auth(req)
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK, http.StatusNoContent:
return nil
case http.StatusMethodNotAllowed:
return fmt.Errorf("%s: mkdir %s returned http %d; the folder may already exist or this WebDAV backend is read-only", p.name, target, resp.StatusCode)
default:
return p.decorateDAVMutationStatusError(resp, "mkdir", target)
}
}
func (p *cloudDrive2Provider) webDAVRename(ctx context.Context, source, target string) error {
req, err := http.NewRequestWithContext(ctx, "MOVE", p.urlFor(source), nil)
if err != nil {
return err
}
p.auth(req)
req.Header.Set("Destination", p.webDAVDestination(target))
req.Header.Set("Overwrite", "F")
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.urlFor(source), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK, http.StatusNoContent:
return nil
default:
return p.decorateDAVMutationStatusError(resp, "rename", source)
}
}
func (p *cloudDrive2Provider) webDAVDestination(target string) string {
raw := p.urlFor(target)
u, err := url.Parse(raw)
if err != nil {
return raw
}
u.RawQuery = ""
u.Fragment = ""
return u.String()
}
func (p *cloudDrive2Provider) decorateDAVMutationStatusError(resp *http.Response, action, target string) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
detail := compactDAVErrorBody(string(body))
if detail == "" {
return fmt.Errorf("%s: %s %s returned http %d", p.name, action, target, resp.StatusCode)
}
return fmt.Errorf("%s: %s %s returned http %d:%s", p.name, action, target, resp.StatusCode, detail)
}
@@ -0,0 +1,240 @@
package cloud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
const pageSize = 500
target := normalizeCloudDAVPath(dir)
out := make([]FileEntry, 0, pageSize)
for pageNum := 1; ; pageNum++ {
payload := map[string]any{
"path": target,
"password": "",
"page": pageNum,
"per_page": pageSize,
"refresh": false,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/list"), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/list"), err)
}
var decoded openListListResponse
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.openListAPIStatusError("list", target, resp.StatusCode)
}
if decodeErr != nil {
return nil, fmt.Errorf("%s: decode api list: %w", p.name, decodeErr)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return nil, fmt.Errorf("%s: api list %s failed: %s", p.name, target, msg)
}
for _, item := range decoded.Data.Content {
name := strings.TrimSpace(item.Name)
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: joinOpenListAPIPath(target, name),
Name: name,
IsDir: item.IsDir,
Size: item.Size,
})
}
total := decoded.Data.Total
if total > 0 {
if len(out) >= total || len(decoded.Data.Content) == 0 {
break
}
continue
}
if len(decoded.Data.Content) == 0 || len(decoded.Data.Content) < pageSize {
break
}
}
return out, nil
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.openListAPIStatusError("get", fileRef, resp.StatusCode)
}
var decoded openListGetResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return nil, fmt.Errorf("%s: decode api get: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return nil, fmt.Errorf("%s: api get %s failed: %s", p.name, fileRef, msg)
}
raw := firstNonEmpty(decoded.Data.RawURL, decoded.Data.URL)
if raw == "" {
return nil, fmt.Errorf("%s: api get %s returned empty raw_url", p.name, fileRef)
}
resolved, err := p.resolveOpenListPlaybackURL(raw)
if err != nil {
return nil, err
}
headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header)
if len(headers) > 0 {
return nil, fmt.Errorf("%s: api get %s returned raw_url that requires headers (%s); refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, strings.Join(sortedHeaderNames(headers), ","))
}
resolved, err = p.resolveOpenListCDNRedirect(ctx, fileRef, resolved)
if err != nil {
return nil, err
}
return &DirectLink{URL: resolved, Headers: nil, Proxy: false}, nil
}
func (p *cloudDrive2Provider) resolveOpenListCDNRedirect(ctx context.Context, fileRef, rawURL string) (string, error) {
if p.apiBase == nil || !sameURLHost(rawURL, p.apiBase) {
return rawURL, nil
}
location, status, err := p.firstHTTPRedirectLocation(ctx, rawURL, nil)
if err != nil {
return "", fmt.Errorf("%s: probe raw_url %s failed: %w", p.name, fileRef, err)
}
if location != "" {
return location, nil
}
return "", fmt.Errorf("%s: api get %s returned an OpenList-hosted raw_url with http %d and no CDN Location; refusing OpenList/WebDAV proxy fallback for pure 302 playback", p.name, fileRef, status)
}
func (p *cloudDrive2Provider) openListAPIStatusError(action, target string, status int) error {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%s: api %s %s returned http %d;请检查 OpenList Token 或用户名密码,并确认填写的是 OpenList 服务地址而不是 /dav 地址", p.name, action, target, status)
}
return fmt.Errorf("%s: api %s %s returned http %d", p.name, action, target, status)
}
func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool {
return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "")
}
func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) {
if token := strings.TrimSpace(p.token); token != "" {
return token, nil
}
if strings.TrimSpace(p.username) == "" || p.password == "" {
return "", nil
}
payload, _ := json.Marshal(map[string]string{
"username": p.username,
"password": p.password,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/auth/login"), bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
resp, err := p.client.Do(req)
if err != nil {
return "", decorateDAVTransportError(p.name, p.openListAPIURL("/api/auth/login"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%s: api login returned http %d", p.name, resp.StatusCode)
}
var decoded openListLoginResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return "", fmt.Errorf("%s: decode api login: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return "", fmt.Errorf("%s: api login failed: %s", p.name, msg)
}
token := strings.TrimSpace(decoded.Data.Token)
if token == "" {
return "", fmt.Errorf("%s: api login returned empty token", p.name)
}
p.token = token
return token, nil
}
func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("%s: empty playback URL", p.name)
}
if strings.HasPrefix(raw, "//") {
if p.apiBase == nil || p.apiBase.Scheme == "" {
return "", fmt.Errorf("%s: protocol-relative playback URL without API base", p.name)
}
raw = p.apiBase.Scheme + ":" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%s: invalid playback URL: %w", p.name, err)
}
if u.IsAbs() {
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("%s: unsupported playback URL scheme %q", p.name, u.Scheme)
}
return u.String(), nil
}
if p.apiBase == nil {
return "", fmt.Errorf("%s: relative playback URL without API base", p.name)
}
base := *p.apiBase
base.RawPath = ""
base.RawQuery = ""
base.Fragment = ""
return base.ResolveReference(u).String(), nil
}
@@ -0,0 +1,126 @@
package cloud
import (
"encoding/json"
"net/url"
"path"
"sort"
"strings"
)
func sortedHeaderNames(headers map[string]string) []string {
if len(headers) == 0 {
return nil
}
out := make([]string, 0, len(headers))
for key := range headers {
key = strings.TrimSpace(key)
if key != "" {
out = append(out, key)
}
}
sort.Strings(out)
return out
}
func sameURLHost(raw string, base *url.URL) bool {
if base == nil {
return false
}
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
if !u.IsAbs() {
return true
}
return strings.EqualFold(u.Host, base.Host)
}
func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
out := make(map[string]string, len(obj))
for k, v := range obj {
key := strings.TrimSpace(k)
if key == "" {
continue
}
switch value := v.(type) {
case string:
if strings.TrimSpace(value) != "" {
out[key] = strings.TrimSpace(value)
}
case []any:
parts := make([]string, 0, len(value))
for _, item := range value {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
parts = append(parts, strings.TrimSpace(s))
}
}
if len(parts) > 0 {
out[key] = strings.Join(parts, ", ")
}
}
}
if len(out) == 0 {
return nil
}
return out
}
func isCloudVideoPlaybackCandidate(fileRef string) bool {
switch strings.ToLower(path.Ext(strings.TrimSpace(fileRef))) {
case ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".webm", ".ts", ".rmvb", ".rm", ".3gp", ".mpg", ".mpeg":
return true
default:
return false
}
}
type openListListResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Content []openListListItem `json:"content"`
Total int `json:"total"`
} `json:"data"`
}
type openListListItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
}
type openListGetResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
RawURL string `json:"raw_url"`
URL string `json:"url"`
Header json.RawMessage `json:"header"`
} `json:"data"`
}
type openListLoginResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
func joinOpenListAPIPath(dir, name string) string {
dir = strings.TrimRight(normalizeCloudDAVPath(dir), "/")
name = strings.Trim(strings.ReplaceAll(name, "\\", "/"), "/")
if dir == "" || dir == "/" {
return normalizeCloudDAVPath(name)
}
return normalizeCloudDAVPath(dir + "/" + name)
}
+171
View File
@@ -0,0 +1,171 @@
package cloud
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
var gotAuth, gotDepth, gotRange string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PROPFIND" && r.URL.Path == "/dav":
gotAuth = r.Header.Get("Authorization")
gotDepth = r.Header.Get("Depth")
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/115/</d:href>
<d:propstat><d:prop><d:displayname>115</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/123/Movie.mkv</d:href>
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>789</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
</d:response>
</d:multistatus>`))
case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv":
gotAuth = r.Header.Get("Authorization")
gotRange = r.Header.Get("Range")
http.Redirect(w, r, "https://cdn.example.test/123/Movie.mkv?sign=1", http.StatusFound)
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if gotDepth != "1" {
t.Fatalf("Depth = %q, want 1", gotDepth)
}
if !strings.HasPrefix(gotAuth, "Basic ") {
t.Fatalf("missing basic auth: %q", gotAuth)
}
if len(entries) != 2 {
t.Fatalf("entries = %#v", entries)
}
if !entries[0].IsDir || entries[0].ID != "/115" {
t.Fatalf("dir entry wrong: %#v", entries[0])
}
if entries[1].IsDir || entries[1].ID != "/123/Movie.mkv" || entries[1].Size != 789 {
t.Fatalf("file entry wrong: %#v", entries[1])
}
link, err := p.Resolve(context.Background(), entries[1].ID)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != "https://cdn.example.test/123/Movie.mkv?sign=1" {
t.Fatalf("bad url: %s", link.URL)
}
if link.Proxy || len(link.Headers) != 0 {
t.Fatalf("clouddrive2 video should resolve to pure 302 link: %#v", link)
}
if gotRange != "bytes=0-0" {
t.Fatalf("resolve should probe with a tiny range, got %q", gotRange)
}
}
func TestCloudDrive2ResolveRejectsWebDAVProxyFallbackWithoutRedirect(t *testing.T) {
var getSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PROPFIND" && r.URL.Path == "/dav":
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>`))
case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv":
getSeen = true
w.Header().Set("Content-Range", "bytes 0-0/10")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("x"))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/123/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "without CDN Location") || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") {
t.Fatalf("resolve error = %v, want pure 302 refusal", err)
}
if !getSeen {
t.Fatal("expected CloudDrive2 WebDAV direct-link probe")
}
}
func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) {
var mkcolSeen bool
var destinations []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "MKCOL" && r.URL.Path == "/dav/TV":
mkcolSeen = true
w.WriteHeader(http.StatusCreated)
case r.Method == "MOVE" && r.URL.Path == "/dav/TV":
destinations = append(destinations, r.Header.Get("Destination"))
if r.Header.Get("Overwrite") != "F" {
t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite"))
}
w.WriteHeader(http.StatusCreated)
case r.Method == "MOVE" && r.URL.Path == "/dav/Inbox/Movie.mkv":
destinations = append(destinations, r.Header.Get("Destination"))
if r.Header.Get("Overwrite") != "F" {
t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite"))
}
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
if err != nil {
t.Fatal(err)
}
mutable, ok := p.(MutableProvider)
if !ok {
t.Fatal("clouddrive2 should support mutable provider")
}
if _, err := mutable.Mkdir(context.Background(), "", "TV"); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil {
t.Fatalf("rename: %v", err)
}
moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/欧美电影/Movie (2026)", "Movie (2026).mkv")
if err != nil {
t.Fatalf("move: %v", err)
}
if !mkcolSeen || len(destinations) != 2 {
t.Fatalf("mkcol=%v destinations=%#v, want mkdir and two MOVE calls", mkcolSeen, destinations)
}
if destinations[0] != srv.URL+"/dav/%E7%94%B5%E8%A7%86%E5%89%A7" {
t.Fatalf("rename Destination = %q", destinations[0])
}
if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" {
t.Fatalf("move Destination = %q", destinations[1])
}
if moved.ID != "/电影/欧美电影/Movie (2026)/Movie (2026).mkv" {
t.Fatalf("moved entry = %#v", moved)
}
}
@@ -0,0 +1,280 @@
package cloud
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestOpenListWebDAVListAndResolve(t *testing.T) {
var gotPath, gotDepth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/auth/login" {
http.NotFound(w, r)
return
}
if r.URL.Path == "/api/fs/get" {
http.NotFound(w, r)
return
}
if r.Method != "PROPFIND" || r.URL.Path != "/dav" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
gotPath = r.URL.Path
gotDepth = r.Header.Get("Depth")
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/Cloud/Movie.mkv</d:href>
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
</d:response>
</d:multistatus>`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client())
if err != nil {
t.Fatal(err)
}
if p.Type() != TypeOpenList {
t.Fatalf("type = %q, want %q", p.Type(), TypeOpenList)
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if gotPath != "/dav" {
t.Fatalf("path = %q, want /dav", gotPath)
}
if gotDepth != "1" {
t.Fatalf("Depth = %q, want 1", gotDepth)
}
if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 {
t.Fatalf("entries = %#v", entries)
}
_, err = p.Resolve(context.Background(), entries[0].ID)
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err)
}
}
func TestOpenListListUsesAPIUsernamePasswordWithoutWebDAVFallback(t *testing.T) {
var loginSeen, listSeen, davSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/auth/login":
loginSeen = true
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode login body: %v", err)
}
if body["username"] != "alice" || body["password"] != "secret" {
t.Fatalf("login body = %#v", body)
}
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`))
case "/api/fs/list":
listSeen = true
if r.Header.Get("Authorization") != "api-token" {
t.Fatalf("Authorization = %q, want api-token", r.Header.Get("Authorization"))
}
_, _ = w.Write([]byte(`{"code":200,"data":{"content":[{"name":"Movies","is_dir":true,"size":0},{"name":"Movie.mkv","is_dir":false,"size":1024}],"total":2}}`))
case "/dav":
davSeen = true
w.WriteHeader(http.StatusMultiStatus)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if !loginSeen || !listSeen {
t.Fatalf("expected api login/list, login=%v list=%v", loginSeen, listSeen)
}
if davSeen {
t.Fatal("openlist API credentials should not fall back to WebDAV")
}
if len(entries) != 2 || entries[0].ID != "/Movies" || !entries[0].IsDir || entries[1].ID != "/Movie.mkv" || entries[1].Size != 1024 {
t.Fatalf("entries = %#v", entries)
}
}
func TestOpenListMutableProviderUsesAPI(t *testing.T) {
var mkdirPath, renamePath, renameName, moveSrcDir, moveDstDir string
var moveNames []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/fs/mkdir":
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode mkdir body: %v", err)
}
mkdirPath = body["path"]
if r.Header.Get("Authorization") != "alist-token" {
t.Fatalf("mkdir Authorization = %q", r.Header.Get("Authorization"))
}
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
case "/api/fs/rename":
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode rename body: %v", err)
}
renamePath = body["path"]
renameName = body["name"]
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
case "/api/fs/move":
var body struct {
SrcDir string `json:"src_dir"`
DstDir string `json:"dst_dir"`
Names []string `json:"names"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode move body: %v", err)
}
moveSrcDir = body.SrcDir
moveDstDir = body.DstDir
moveNames = body.Names
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
mutable, ok := p.(MutableProvider)
if !ok {
t.Fatal("openlist should support mutable provider")
}
created, err := mutable.Mkdir(context.Background(), "/电视剧", "欧美剧")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
if mkdirPath != "/电视剧/欧美剧" || created.ID != "/电视剧/欧美剧" || !created.IsDir {
t.Fatalf("mkdir path=%q entry=%#v", mkdirPath, created)
}
renamed, err := mutable.Rename(context.Background(), "/电视剧/欧美剧", "美剧")
if err != nil {
t.Fatalf("rename: %v", err)
}
if renamePath != "/电视剧/欧美剧" || renameName != "美剧" || renamed.ID != "/电视剧/美剧" {
t.Fatalf("rename path=%q name=%q entry=%#v", renamePath, renameName, renamed)
}
moved, err := mutable.(MovableProvider).Move(context.Background(), "/待整理/Show.S01E01.mkv", "/动漫/国漫/Show/Season 01", "Show - S01E01.mkv")
if err != nil {
t.Fatalf("move: %v", err)
}
if moveSrcDir != "/待整理" || moveDstDir != "/动漫/国漫/Show/Season 01" || len(moveNames) != 1 || moveNames[0] != "Show.S01E01.mkv" {
t.Fatalf("move src=%q dst=%q names=%#v", moveSrcDir, moveDstDir, moveNames)
}
if renamePath != "/动漫/国漫/Show/Season 01/Show.S01E01.mkv" || renameName != "Show - S01E01.mkv" {
t.Fatalf("post-move rename path=%q name=%q", renamePath, renameName)
}
if moved.ID != "/动漫/国漫/Show/Season 01/Show - S01E01.mkv" {
t.Fatalf("moved entry = %#v", moved)
}
}
func TestOpenListListAPIFailureDoesNotFallbackToWebDAV(t *testing.T) {
var davSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/auth/login":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":500,"message":"bad password"}`))
case "/dav":
davSeen = true
w.WriteHeader(http.StatusMultiStatus)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "bad"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.List(context.Background(), "")
if err == nil || !strings.Contains(err.Error(), "api login failed") || !strings.Contains(err.Error(), "bad password") {
t.Fatalf("list error = %v, want api login failure", err)
}
if davSeen {
t.Fatal("openlist API failure fell back to WebDAV")
}
}
func TestOpenListRootURLDefaultsToDAV(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/"}, srv.Client())
if err != nil {
t.Fatal(err)
}
if _, err := p.List(context.Background(), ""); err != nil {
t.Fatalf("list: %v", err)
}
if gotPath != "/dav" {
t.Fatalf("path = %q, want /dav", gotPath)
}
}
func TestOpenListURLForKeepsNonASCIIPathSingleEncoded(t *testing.T) {
p := newOpenList(map[string]any{"url": "http://example.test:5244/dav/"}, nil)
got := p.urlFor("/动画电影/爱宠大机密2 (2019) {tmdb-412117}")
if strings.Contains(got, "%25E") {
t.Fatalf("url is double-escaped: %s", got)
}
want := "http://example.test:5244/dav/%E5%8A%A8%E7%94%BB%E7%94%B5%E5%BD%B1/%E7%88%B1%E5%AE%A0%E5%A4%A7%E6%9C%BA%E5%AF%862%20%282019%29%20%7Btmdb-412117%7D"
if got != want {
t.Fatalf("url = %s, want %s", got, want)
}
}
func TestOpenListDAVStatusErrorIncludesBodyHint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = w.Write([]byte("请先填写有效Cookie并保存"))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.List(context.Background(), "")
if err == nil {
t.Fatal("want error")
}
if !strings.Contains(err.Error(), "请先填写有效Cookie并保存") || !strings.Contains(err.Error(), "WebDAV 地址") {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -0,0 +1,204 @@
package cloud
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) {
var gotPath, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if gotPath != "/api/fs/get" {
t.Fatalf("api path = %q, want /api/fs/get", gotPath)
}
if gotAuth != "alist-token" {
t.Fatalf("Authorization = %q, want token", gotAuth)
}
if link.URL != "https://cdn.example.test/movie.mkv?sign=1" {
t.Fatalf("url = %q", link.URL)
}
if link.Proxy {
t.Fatalf("openlist raw_url without required headers should be 302 playback")
}
}
func TestOpenListResolveCollapsesHostedRawURLRedirectToCDN(t *testing.T) {
var probeSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/fs/get":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`))
case "/d/Cloud/Movie.mkv":
probeSeen = true
if r.Header.Get("Range") != "bytes=0-0" {
t.Fatalf("probe Range = %q", r.Header.Get("Range"))
}
http.Redirect(w, r, "https://cdn.example.test/movie.mkv?sign=cdn", http.StatusFound)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !probeSeen {
t.Fatal("expected OpenList-hosted raw_url probe")
}
if link.URL != "https://cdn.example.test/movie.mkv?sign=cdn" || link.Proxy || len(link.Headers) != 0 {
t.Fatalf("link = %#v, want collapsed CDN 302 playback", link)
}
}
func TestOpenListResolveLogsInWithUsernamePasswordForAPIRawURL(t *testing.T) {
var loginSeen bool
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/auth/login":
loginSeen = true
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode login body: %v", err)
}
if body["username"] != "alice" || body["password"] != "secret" {
t.Fatalf("login body = %#v", body)
}
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`))
case "/api/fs/get":
gotAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !loginSeen {
t.Fatalf("expected api login before fs/get")
}
if gotAuth != "api-token" {
t.Fatalf("Authorization = %q, want api-token", gotAuth)
}
if link.URL != "https://cdn.example.test/movie.mkv?sign=1" || link.Proxy {
t.Fatalf("link = %#v, want raw_url 302 playback", link)
}
}
func TestOpenListResolveRejectsProxyWhenAPIRawURLNeedsHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/fs/get" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/dav/Cloud/Movie.mkv","header":{"Cookie":"sid=abc"}}}`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") || !strings.Contains(err.Error(), "Cookie") {
t.Fatalf("resolve error = %v, want pure 302 refusal with header names", err)
}
}
func TestOpenListResolveRejectsHostedRawURLWithoutCDNRedirect(t *testing.T) {
var probeSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/fs/get":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`))
case "/d/Cloud/Movie.mkv":
probeSeen = true
w.Header().Set("Content-Range", "bytes 0-0/10")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("x"))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "OpenList-hosted raw_url") || !strings.Contains(err.Error(), "no CDN Location") {
t.Fatalf("resolve error = %v, want hosted raw_url refusal", err)
}
if !probeSeen {
t.Fatal("expected OpenList-hosted raw_url probe")
}
}
func TestOpenListResolveDoesNotFallbackToWebDAVWhenAPIRawURLFails(t *testing.T) {
var davSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/fs/get":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":500,"message":"driver cannot provide raw_url"}`))
case "/dav/Cloud/Movie.mkv":
davSeen = true
w.WriteHeader(http.StatusOK)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
t.Fatalf("resolve error = %v, want raw_url requirement", err)
}
if davSeen {
t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure")
}
}
+94
View File
@@ -0,0 +1,94 @@
// 115 开放平台(openapi)驱动:替代原 cookie 逆向方案。
//
// 账号配置(StrmAccount.Config JSON):
//
// {
// "app_id": "100195125", // 开放平台应用 ID
// "access_token": "...", // 加密存储
// "refresh_token": "...", // 加密存储
// "user_id": "12345", // 可选
// "user_name": "user" // 可选
// }
//
// 授权流程:设备码扫码(官方 PKCE 应用目录)、QMediaSync/MQFamily 中继、
// MoviePilot 轮询、CloudDrive 回跳,见 internal/service/cloud115。
package cloud
import (
"context"
"fmt"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
)
// openAPI115Provider 实现 Provider 接口:List 列目录、Resolve 用 pickcode
// 换下载直链(302 offload,无需代理)、Ping 探测根目录。
type openAPI115Provider struct {
c *cloud115.OpenClient
}
// NewOpenAPI115 构造 115 开放平台驱动。
func NewOpenAPI115(appID, accessToken, refreshToken string) *openAPI115Provider {
return &openAPI115Provider{c: cloud115.NewOpenClient(strings.TrimSpace(appID), accessToken, refreshToken)}
}
func (p *openAPI115Provider) Type() string { return Type115 }
func (p *openAPI115Provider) Ping(ctx context.Context) error {
if strings.TrimSpace(p.c.AppID) == "" {
return fmt.Errorf("115: 缺少开放平台应用 ID,请重新授权")
}
if strings.TrimSpace(p.c.AccessToken) == "" {
return fmt.Errorf("115: 缺少访问令牌,请重新授权")
}
_, _, err := p.c.GetFsList(ctx, "0", 0, 1)
return err
}
func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
// 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录
const pageSize = 100
var out []FileEntry
for offset := 0; ; offset += pageSize {
files, _, err := p.c.GetFsList(ctx, dirID, offset, pageSize)
if err != nil {
return nil, err
}
for _, f := range files {
out = append(out, FileEntry{
ID: f.FileId,
Name: f.FileName,
IsDir: f.Category == cloud115.TypeDir,
Size: f.FileSize,
PickCode: f.PickCode,
})
}
if len(files) < pageSize {
break
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
return out, nil
}
func (p *openAPI115Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
url, err := p.c.GetDownloadURL(ctx, fileRef)
if err != nil {
return nil, err
}
return &DirectLink{URL: url, Proxy: false}, nil
}
// OpenClient 暴露底层客户端(token 刷新用)。
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
// RefreshToken 刷新访问令牌并返回新令牌;refresh_token 失效时返回
// cloud115.IsRefreshTokenDead(err) 为 true 的错误。
func (p *openAPI115Provider) RefreshToken(refreshToken string) (*cloud115.TokenData, error) {
return p.c.RefreshToken(refreshToken)
}
+238
View File
@@ -0,0 +1,238 @@
// 115 开放平台 HTTP 客户端(移植自 QMediaSync 的 v115open,去掉 resty 依赖,
// 使用 net/http + 简单限流重试;只保留只读能力:授权/列目录/详情/下载直链)。
package cloud115
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
)
// OpenClient 是 115 开放平台客户端。
type OpenClient struct {
AppID string
HTTP *http.Client
AccessToken string
RefreshTokenStr string
// 全局 QPS 限流(115 开放平台免费额度较低)
lastSecond int64
reqInSecond int64
}
var openClientMu sync.Mutex
// NewOpenClient 构造客户端。
func NewOpenClient(appID, accessToken, refreshToken string) *OpenClient {
return &OpenClient{
AppID: appID,
HTTP: &http.Client{Timeout: 60 * time.Second},
AccessToken: accessToken,
RefreshTokenStr: refreshToken,
}
}
// SetAuthToken 更新认证令牌。
func (c *OpenClient) SetAuthToken(accessToken, refreshToken string) {
c.AccessToken = accessToken
c.RefreshTokenStr = refreshToken
}
// throttle 简单的每秒限流(默认 4 QPS,115 免费应用限额约 5 QPS)。
func (c *OpenClient) throttle(n int) {
for i := 0; i < n; i++ {
now := time.Now().Unix()
last := atomic.LoadInt64(&c.lastSecond)
if last != now {
if atomic.CompareAndSwapInt64(&c.lastSecond, last, now) {
atomic.StoreInt64(&c.reqInSecond, 0)
}
}
count := atomic.LoadInt64(&c.reqInSecond)
if count >= 4 {
time.Sleep(300 * time.Millisecond)
i--
continue
}
if atomic.CompareAndSwapInt64(&c.reqInSecond, count, count+1) {
return
}
time.Sleep(50 * time.Millisecond)
i--
}
}
// RespState 兼容 115 不同端点返回的 state 类型(proapi 返回布尔、passport 返回数字)。
type RespState bool
func (s *RespState) UnmarshalJSON(data []byte) error {
switch string(data) {
case "true", "1":
*s = true
return nil
case "false", "0", "null", "":
*s = false
return nil
}
var n float64
if err := json.Unmarshal(data, &n); err != nil {
return fmt.Errorf("115: 无法解析 state 字段 %s", string(data))
}
*s = n != 0
return nil
}
// RespBase 是 115 开放平台统一响应外壳。
type RespBase struct {
State RespState `json:"state"`
Code int `json:"code"`
Errno int `json:"errno"`
Message string `json:"message"`
Error string `json:"error"`
Data json.RawMessage `json:"data"`
Raw json.RawMessage `json:"-"` // 原始响应体(外层附加字段用)
}
// doJSON 执行 GET 请求并解析为统一响应;带 AccessToken(access=true 时)。
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int) (*RespBase, error) {
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
c.throttle(1)
req, err := c.buildRequest(ctx, method, rawURL, form, access)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
lastErr = err
if attempt < retries {
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
continue
}
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
_ = resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
lastErr = fmt.Errorf("115 接口返回 HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(body)))
if attempt < retries {
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
continue
}
return nil, lastErr
}
var base RespBase
if err := json.Unmarshal(body, &base); err != nil {
return nil, fmt.Errorf("115 接口响应解析失败:%w", err)
}
base.Raw = body
if base.State {
return &base, nil
}
// 业务失败:限流/Token 错误不重试,其余按配置重试
if IsThrottleCode(base.Code) || isTokenCode(base.Code) {
return &base, nil
}
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115 接口调用失败")
if attempt < retries {
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
continue
}
return &base, lastErr
}
return nil, lastErr
}
func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, form map[string]string, access bool) (*http.Request, error) {
method = strings.ToUpper(method)
var body io.Reader
if method == http.MethodPost && len(form) > 0 {
values := url.Values{}
for k, v := range form {
values.Set(k, v)
}
body = bytes.NewBufferString(values.Encode())
} else if method == http.MethodGet && len(form) > 0 {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
query := u.Query()
for k, v := range form {
query.Set(k, v)
}
u.RawQuery = query.Encode()
rawURL = u.String()
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", DefaultUA)
if method == http.MethodPost && len(form) > 0 {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if access && c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
return req, nil
}
// doAuthJSON 带 AccessToken 的业务请求。
func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form map[string]string, retries int) (*RespBase, error) {
return c.doJSON(ctx, method, rawURL, form, true, retries)
}
// IsThrottleCode 判断是否为限流错误码。
func IsThrottleCode(code int) bool {
return code == RequestMaxLimitCode || code == RequestRateLimitCode
}
func isTokenCode(code int) bool {
switch code {
case AccessTokenAuthFail, AccessAuthInvalid, AccessTokenExpiryCode, RefreshTokenInvalid:
return true
}
return false
}
// openList 解析 data 为对象或数组(StructOrArray 语义)。
func openList[T any](raw json.RawMessage) ([]T, error) {
var single T
if err := json.Unmarshal(raw, &single); err == nil {
return []T{single}, nil
}
var arr []T
if err := json.Unmarshal(raw, &arr); err == nil {
return arr, nil
}
return nil, fmt.Errorf("115: data 既不是对象也不是数组")
}
// openFirstList 取 data 的第一个元素。
func openFirstList[T any](raw json.RawMessage) (*T, error) {
items, err := openList[T](raw)
if err != nil || len(items) == 0 {
return nil, err
}
return &items[0], nil
}
func firstOrEmpty(m map[string]downloadURLData) downloadURLData {
for _, v := range m {
return v
}
return downloadURLData{}
}
+323
View File
@@ -0,0 +1,323 @@
package cloud115
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// mockTransport 用 httptest server 替换 API 基址。
func mockAPI(t *testing.T, handler http.HandlerFunc) func() {
t.Helper()
pro := httptest.NewServer(handler)
passport := httptest.NewServer(handler)
qr := httptest.NewServer(handler)
oldPro, oldPassport, oldQR := ProAPIBase, PassportAPIBase, QRCodeAPIBase
ProAPIBase, PassportAPIBase, QRCodeAPIBase = pro.URL, passport.URL, qr.URL
t.Cleanup(func() {
ProAPIBase, PassportAPIBase, QRCodeAPIBase = oldPro, oldPassport, oldQR
pro.Close()
passport.Close()
qr.Close()
})
return func() {}
}
func TestGetQrCode(t *testing.T) {
var called bool
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
called = true
if r.URL.Path != "/open/authDeviceCode" {
t.Errorf("unexpected path %s", r.URL.Path)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostFormValue("client_id") != "100195125" {
t.Errorf("bad client_id %q", r.PostFormValue("client_id"))
}
if r.PostFormValue("code_challenge") == "" {
t.Errorf("missing code_challenge")
}
w.Write([]byte(`{"state":true,"data":{"uid":"U1","time":1700,"sign":"S1","qrcode":"https://img/qr.png"}}`))
})
c := NewOpenClient("100195125", "", "")
qr, err := c.GetQrCode()
if err != nil {
t.Fatalf("get qr: %v", err)
}
if !called {
t.Fatal("request not hit")
}
if qr.Uid != "U1" || qr.Qrcode == "" || qr.CodeVerifier == "" || len(qr.CodeVerifier) != 64 {
t.Fatalf("bad qr data: %#v", qr)
}
}
func TestQrCodeScanStatusSequence(t *testing.T) {
calls := 0
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/get/status/" {
t.Errorf("unexpected path %s", r.URL.Path)
}
calls++
switch calls {
case 1:
w.Write([]byte(`{"state":true,"data":{"status":0}}`))
case 2:
w.Write([]byte(`{"state":true,"data":{"status":1}}`))
default:
w.Write([]byte(`{"state":true,"data":{"status":2}}`))
}
})
c := NewOpenClient("", "", "")
code := &QrCodeData{Uid: "U1", Time: 1700, Sign: "S1"}
want := []QrCodeScanStatus{QrCodeScanStatusNotScanned, QrCodeScanStatusScanned, QrCodeScanStatusConfirmed}
for i, exp := range want {
got, err := c.QrCodeScanStatus(code)
if err != nil {
t.Fatalf("status %d: %v", i, err)
}
if got != exp {
t.Fatalf("status %d: got %v want %v", i, got, exp)
}
}
}
func TestGetTokenAndRefresh(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/deviceCodeToToken":
w.Write([]byte(`{"state":true,"data":{"access_token":"at1","refresh_token":"rt1","expires_in":7200}}`))
case "/open/refreshToken":
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostFormValue("refresh_token") != "rt2" {
t.Errorf("bad refresh_token %q", r.PostFormValue("refresh_token"))
}
w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
c := NewOpenClient("100195125", "", "")
token, err := c.GetToken(&QrCodeDataReturn{QrCodeData: QrCodeData{Uid: "U1"}, CodeVerifier: "v"})
if err != nil {
t.Fatalf("get token: %v", err)
}
if token.AccessToken != "at1" || c.AccessToken != "at1" {
t.Fatalf("bad token: %#v", token)
}
token, err = c.RefreshToken("rt2")
if err != nil {
t.Fatalf("refresh: %v", err)
}
if token.AccessToken != "at2" || c.RefreshTokenStr != "rt2" {
t.Fatalf("bad refresh result: %#v", token)
}
}
func TestRefreshTokenDead(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"state":false,"code":40140119,"message":"refresh_token 已过期"}`))
})
c := NewOpenClient("100195125", "at", "rt-dead")
_, err := c.RefreshToken("rt-dead")
if err == nil {
t.Fatal("expected error")
}
if !IsRefreshTokenDead(err) {
t.Fatalf("expected dead refresh token error, got %v", err)
}
if c.AccessToken != "" {
t.Fatalf("dead token should clear access token")
}
}
func TestFsListAndDownload(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/ufile/files":
if r.Header.Get("Authorization") != "Bearer at1" {
t.Errorf("missing auth header")
}
w.Write([]byte(`{"state":true,"path":[{"name":"根目录","cid":0}],"data":[
{"fid":"100","fc":"0","fn":"Movies","fs":0},
{"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`))
case "/open/ufile/downurl":
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostFormValue("pick_code") != "pickA" {
t.Errorf("bad pick_code")
}
w.Write([]byte(`{"state":true,"data":{"200":{"file_name":"a.mkv","url":{"url":"https://cdn/x.mkv"}}}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
c := NewOpenClient("100195125", "at1", "rt1")
files, pathStr, err := c.GetFsList(context.Background(), "0", 0, 100)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(files) != 2 {
t.Fatalf("want 2 files, got %d", len(files))
}
if !files[0].IsDir() {
t.Fatalf("first entry should be dir")
}
if pathStr != "根目录" {
t.Fatalf("path_str = %q", pathStr)
}
url, err := c.GetDownloadURL(context.Background(), "pickA")
if err != nil {
t.Fatalf("downurl: %v", err)
}
if url != "https://cdn/x.mkv" {
t.Fatalf("bad url %q", url)
}
}
func (f RemoteFile) IsDir() bool { return f.Category == TypeDir }
func TestGetFsDetailByCid(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/folder/get_info" {
t.Errorf("unexpected path %s", r.URL.Path)
}
w.Write([]byte(`{"state":true,"data":{"file_id":"100","file_name":"Movies","file_category":"0","size_byte":123}}`))
})
c := NewOpenClient("100195125", "at1", "rt1")
detail, err := c.GetFsDetailByCid(context.Background(), "100")
if err != nil {
t.Fatalf("detail: %v", err)
}
if detail.FileId != "100" || detail.FileName != "Movies" {
t.Fatalf("bad detail: %#v", detail)
}
}
// TestRelayRoundTrip 中继加解密往返 + 回调解析。
func TestRelayRoundTrip(t *testing.T) {
RelayEncryptionKey = "unit-test-shared-key"
defer func() { RelayEncryptionKey = "" }()
payload := `{"data":{"access_token":"at","refresh_token":"rt","expires_in":7200}}`
encrypted, err := EncryptRelay(payload)
if err != nil {
t.Fatal(err)
}
decrypted, err := DecryptRelay(encrypted)
if err != nil {
t.Fatal(err)
}
if decrypted != payload {
t.Fatalf("round trip mismatch: %q", decrypted)
}
provider, err := GetOAuthProvider(Source{SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: "QMediaSync"})
if err != nil {
t.Fatalf("relay provider should be available when key configured: %v", err)
}
token, err := provider.Confirm(context.Background(), map[string]string{"data": encrypted})
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !token.Done || token.AccessToken != "at" || token.RefreshToken != "rt" {
t.Fatalf("bad token: %#v", token)
}
}
func TestRelayProviderRequiresKey(t *testing.T) {
RelayEncryptionKey = ""
if _, err := GetOAuthProvider(Source{SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: "QMediaSync"}); err == nil {
t.Fatal("relay provider should fail without key")
}
if RelayAvailable() {
t.Fatal("RelayAvailable should be false without key")
}
}
func TestMoviePilotProvider(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/u115/auth_url":
w.Write([]byte(`{"auth_url":"https://passport.115/auth?state=ST","state":"ST"}`))
case "/u115/token":
if r.URL.Query().Get("state") != "ST" {
t.Errorf("bad state")
}
w.Write([]byte(`{"state":true,"data":{"access_token":"at","refresh_token":"rt","expires_in":7200}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
provider := moviePilotOAuthProvider{authServer: srv.URL}
result, err := provider.BuildAuth(context.Background(), OAuthURLRequest{})
if err != nil {
t.Fatalf("build: %v", err)
}
if result.AuthURL == "" || result.State != "ST" || !result.Polling {
t.Fatalf("bad result: %#v", result)
}
token, err := provider.Poll(context.Background(), "ST")
if err != nil {
t.Fatalf("poll: %v", err)
}
if !token.Done || token.AccessToken != "at" {
t.Fatalf("bad token: %#v", token)
}
}
func TestCloudDriveBuildAuth(t *testing.T) {
provider := cloudDriveOAuthProvider{source: Source{SourceType: SourceTypeThirdPartyService, Provider: ProviderCloudDrive, AppID: "100195313"}}
result, err := provider.BuildAuth(context.Background(), OAuthURLRequest{
RedirectURL: "http://127.0.0.1:8080/api/strm/oauth/callback",
AuthorizationID: "auth-123",
})
if err != nil {
t.Fatalf("build: %v", err)
}
if !strings.Contains(result.AuthURL, "client_id=100195313") {
t.Fatalf("bad auth url: %s", result.AuthURL)
}
parsed, err := url.Parse(result.AuthURL)
if err != nil {
t.Fatalf("parse auth url: %v", err)
}
state, err := url.QueryUnescape(parsed.Query().Get("state"))
if err != nil {
t.Fatalf("unescape state: %v", err)
}
if !strings.Contains(state, "authorization_id=auth-123") {
t.Fatalf("missing auth id in state: %s", result.AuthURL)
}
token, err := provider.Confirm(context.Background(), map[string]string{
"access_token": "at", "refresh_token": "rt", "expires_in": "7200",
})
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !token.Done || token.AccessToken != "at" {
t.Fatalf("bad token: %#v", token)
}
}
func TestSourceCatalog(t *testing.T) {
if len(BuiltInAppIDSources()) < 50 {
t.Fatalf("built-in app catalog too small: %d", len(BuiltInAppIDSources()))
}
if _, ok := FindSource(SourceTypeBuiltInAppID, ProviderOfficialPKCE, "100195125"); !ok {
t.Fatal("媒体播放器 app not found")
}
if len(BuiltInRelaySources()) == 0 || len(ThirdPartySources()) != 2 {
t.Fatal("relay/thrid-party sources broken")
}
}
+97
View File
@@ -0,0 +1,97 @@
// 115 开放平台常量与错误类型。
package cloud115
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
)
func sha256Sum(data []byte) [32]byte { return sha256.Sum256(data) }
func base64StdEncode(data []byte) string { return base64.StdEncoding.EncodeToString(data) }
// 115 开放平台 API 地址(变量便于测试注入 mock server)
var (
ProAPIBase = "https://proapi.115.com"
PassportAPIBase = "https://passportapi.115.com"
QRCodeAPIBase = "https://qrcodeapi.115.com"
FSPIsAPIBase = "https://fsapi.115.com"
)
const (
// 业务错误码
AccessTokenAuthFail = 40140126 // 访问过期,需刷新
AccessTokenExpiryCode = 40140125 // 访问过期,需刷新
AccessAuthInvalid = 40140124 // 访问无效,需刷新
RefreshTokenInvalid = 40140116 // 需重新授权
TokenRefreshFail = 40140121 // 刷新失败,可重试
RequestMaxLimitCode = 770004 // 访问频率过高
RequestRateLimitCode = 406 // 达到访问上限
// 刷新 token 的错误码
RefreshTokenFormatInvalid = 40140114
RefreshTokenSignInvalid = 40140115
RefreshTooFrequent = 40140117
RefreshTokenExpired = 40140119
RefreshTokenCheckFailed = 40140120
)
// DefaultUA 请求 UA。
const DefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-115-OpenAPI/1.0"
// OpenAPIError 保留 115 开放平台返回的原始错误信息。
type OpenAPIError struct {
Code int
Message string
}
func (e *OpenAPIError) Error() string {
if e.Code == 0 {
return fmt.Sprintf("115 接口错误:%s", e.Message)
}
return fmt.Sprintf("115 接口错误(%d):%s", e.Code, e.Message)
}
// NewOpenAPIResponseError 组装接口错误。
func NewOpenAPIResponseError(code, errno int, message, errorText, fallback string) error {
if code == 0 {
code = errno
}
if message == "" {
message = errorText
}
if code == 0 && message == "" {
return fmt.Errorf("%s", fallback)
}
return NewOpenAPIError(code, message)
}
// NewOpenAPIError 构造接口错误。
func NewOpenAPIError(code int, message string) *OpenAPIError {
if message == "" {
message = "未知错误"
}
return &OpenAPIError{Code: code, Message: message}
}
// IsRefreshTokenDead 判断 refresh_token 是否已无法继续使用。
func IsRefreshTokenDead(err error) bool {
if err == nil {
return false
}
var apiErr *OpenAPIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case RefreshTokenFormatInvalid, RefreshTokenSignInvalid, RefreshTokenInvalid, RefreshTokenExpired, RefreshTokenCheckFailed:
return true
}
}
return false
}
// genCodeChallenge 生成 PKCE code_challenge(sha256(codeVerifier) base64)。
func genCodeChallenge(codeVerifier string) string {
sum := sha256Sum([]byte(codeVerifier))
return base64StdEncode(sum[:])
}
+468
View File
@@ -0,0 +1,468 @@
// 115 OAuth 授权来源:内置应用(设备码扫码)、QMediaSync/MQFamily 中继
// (需共享 AES 密钥)、MoviePilot 轮询、CloudDrive 回跳。
// 逻辑移植自 QMediaSync 的 v115auth。
package cloud115
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// OAuthURLRequest 是发起授权的上下文。
type OAuthURLRequest struct {
Source Source
RedirectURL string // 本服务回调地址(中继/CloudDrive 回跳用)
AuthorizationID string
QRCode *QrCodeDataReturn `json:"-"` // 设备码模式:调用方预取的二维码
}
// OAuthURLResult 是授权发起结果。
type OAuthURLResult struct {
AuthURL string `json:"auth_url,omitempty"`
State string `json:"state,omitempty"`
Polling bool `json:"polling"`
ExpiresIn int64 `json:"expires_in,omitempty"`
// QRCode 非空表示设备码扫码模式(uid/time/sign/qrcode 一并返回)
QRCode *QrCodeDataReturn `json:"qrcode,omitempty"`
}
// OAuthTokenResult 是授权完成结果。
type OAuthTokenResult struct {
AccessToken string
RefreshToken string
ExpiresIn int64
Done bool
}
// OAuthProvider 描述一种 115 授权来源。
type OAuthProvider interface {
BuildAuth(ctx context.Context, req OAuthURLRequest) (OAuthURLResult, error)
Confirm(ctx context.Context, payload map[string]string) (OAuthTokenResult, error)
Poll(ctx context.Context, state string) (OAuthTokenResult, error)
}
var errUnsupportedOAuthOperation = errors.New("当前授权服务不支持此操作")
// GetOAuthProvider 按授权来源取 provider:
// - official_pkce:设备码扫码(需 AppID)
// - qmediasync/mqfamily:中继授权(需配置 strm.115_relay_key)
// - moviepilot / clouddrive:第三方服务
func GetOAuthProvider(source Source) (OAuthProvider, error) {
switch source.Provider {
case ProviderOfficialPKCE:
if strings.TrimSpace(source.AppID) == "" {
return nil, fmt.Errorf("缺少 115 开放平台应用 ID")
}
return deviceCodeOAuthProvider{}, nil
case ProviderQMediaSync, ProviderMQFamily:
if !RelayAvailable() {
return nil, fmt.Errorf("中继授权需要配置共享加密密钥(设置 strm.115_relay_key)")
}
return relayOAuthProvider{source: source}, nil
case ProviderMoviePilot:
return moviePilotOAuthProvider{authServer: oauthServerOr(source.AuthServer, "https://movie-pilot.org")}, nil
case ProviderCloudDrive:
return cloudDriveOAuthProvider{source: source}, nil
default:
return nil, fmt.Errorf("不支持的 115 授权来源")
}
}
func oauthServerOr(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimRight(strings.TrimSpace(value), "/")
}
// deviceCodeOAuthProvider 走 115 官方设备码扫码(无回调、无共享密钥)。
type deviceCodeOAuthProvider struct{}
func (deviceCodeOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
if req.QRCode != nil {
return OAuthURLResult{QRCode: req.QRCode, ExpiresIn: 300}, nil
}
return OAuthURLResult{}, fmt.Errorf("缺少设备码数据")
}
func (deviceCodeOAuthProvider) Confirm(_ context.Context, _ map[string]string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
func (deviceCodeOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
// relayOAuthProvider 走 QMediaSync/MQFamily 中继授权。
type relayOAuthProvider struct {
source Source
}
func (provider relayOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
clientID := strings.TrimSpace(provider.source.AppID)
if clientID == "" {
clientID = BuiltInRelayQ115STRM
if provider.source.Provider == ProviderQMediaSync {
clientID = BuiltInRelayQMediaSync
}
}
redirectURL := strings.TrimSpace(req.RedirectURL)
if redirectURL != "" {
var err error
redirectURL, err = appendCallbackParams(redirectURL, url.Values{
"source": []string{"115"},
"authorization_id": []string{req.AuthorizationID},
})
if err != nil {
return OAuthURLResult{}, err
}
}
stateObj := struct {
State string `json:"state"`
Time int64 `json:"time"`
ClientId string `json:"client_id"`
RedirectURL string `json:"redirect_url"`
AuthorizationID string `json:"authorization_id,omitempty"`
}{
State: RandomString(16),
Time: time.Now().Unix(),
ClientId: clientID,
RedirectURL: redirectURL,
AuthorizationID: req.AuthorizationID,
}
stateJSON, _ := json.Marshal(stateObj)
stateEncoded, err := EncryptRelay(string(stateJSON))
if err != nil {
return OAuthURLResult{}, err
}
baseURL := oauthServerOr(provider.source.AuthServer, "https://api.mqfamily.top")
if provider.source.Provider == ProviderQMediaSync {
baseURL = oauthServerOr(provider.source.AuthServer, "https://oauth.qmediasync.cn")
}
return OAuthURLResult{AuthURL: fmt.Sprintf("%s/115.php?action=code&state=%s", baseURL, stateEncoded)}, nil
}
func (provider relayOAuthProvider) Confirm(_ context.Context, payload map[string]string) (OAuthTokenResult, error) {
data := payload["data"]
if data == "" {
return OAuthTokenResult{}, fmt.Errorf("缺少中转回调数据")
}
decryptedData, err := DecryptRelay(data)
if err != nil {
return OAuthTokenResult{}, err
}
var resp struct {
Data struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
} `json:"data"`
Error string `json:"error"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(decryptedData), &resp); err != nil {
return OAuthTokenResult{}, err
}
if resp.Data.AccessToken == "" || resp.Data.RefreshToken == "" {
if resp.Error != "" {
return OAuthTokenResult{}, errors.New(resp.Error)
}
if resp.Message != "" {
return OAuthTokenResult{}, errors.New(resp.Message)
}
return OAuthTokenResult{}, fmt.Errorf("中转回调未返回访问凭证")
}
return OAuthTokenResult{AccessToken: resp.Data.AccessToken, RefreshToken: resp.Data.RefreshToken, ExpiresIn: resp.Data.ExpiresIn, Done: true}, nil
}
func (provider relayOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
// moviePilotOAuthProvider 走 MoviePilot 的 115 授权服务(轮询)。
type moviePilotOAuthProvider struct {
authServer string
}
func (provider moviePilotOAuthProvider) BuildAuth(ctx context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
endpoint := provider.authServer + "/u115/auth_url"
resp, err := httpGetJSON(ctx, endpoint)
if err != nil {
return OAuthURLResult{}, err
}
authURL := stringField(resp, "auth_url")
state := stringField(resp, "state")
if authURL == "" || state == "" {
return OAuthURLResult{}, fmt.Errorf("MoviePilot 授权服务响应缺少 auth_url 或 state")
}
return OAuthURLResult{AuthURL: authURL, State: state, Polling: true, ExpiresIn: 300}, nil
}
func (provider moviePilotOAuthProvider) Confirm(_ context.Context, _ map[string]string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
func (provider moviePilotOAuthProvider) Poll(ctx context.Context, state string) (OAuthTokenResult, error) {
if state == "" {
return OAuthTokenResult{}, fmt.Errorf("缺少授权状态")
}
endpoint := provider.authServer + "/u115/token?state=" + url.QueryEscape(state)
resp, err := httpGetJSON(ctx, endpoint)
if err != nil {
return OAuthTokenResult{}, err
}
return tokenResultFromMap(resp), nil
}
// cloudDriveOAuthProvider 走 CloudDrive 中转(115 授权页 → zhenyunpan 换 token → 回跳)。
type cloudDriveOAuthProvider struct {
source Source
}
func (provider cloudDriveOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
if strings.TrimSpace(req.RedirectURL) == "" {
return OAuthURLResult{}, fmt.Errorf("CloudDrive 授权需要回跳地址")
}
callback, err := appendCallbackParams(req.RedirectURL, url.Values{
"source": []string{"115"},
"authorization_id": []string{req.AuthorizationID},
})
if err != nil {
return OAuthURLResult{}, err
}
clientID := strings.TrimSpace(provider.source.AppID)
if clientID == "" {
clientID = "100195313"
}
redirectURI := strings.TrimSpace(provider.source.RedirectURI)
if redirectURI == "" {
redirectURI = "https://redirect115.zhenyunpan.com"
}
authURL, _ := url.Parse("https://passportapi.115.com/open/authorize")
query := authURL.Query()
query.Set("client_id", clientID)
query.Set("redirect_uri", redirectURI)
query.Set("response_type", "code")
query.Set("state", callback)
authURL.RawQuery = query.Encode()
return OAuthURLResult{AuthURL: authURL.String(), ExpiresIn: 300}, nil
}
func (provider cloudDriveOAuthProvider) Confirm(_ context.Context, payload map[string]string) (OAuthTokenResult, error) {
expiresIn, _ := strconv.ParseInt(payload["expires_in"], 10, 64)
token := OAuthTokenResult{
AccessToken: payload["access_token"],
RefreshToken: payload["refresh_token"],
ExpiresIn: expiresIn,
}
token.Done = token.AccessToken != "" && token.RefreshToken != ""
if !token.Done {
return OAuthTokenResult{}, fmt.Errorf("CloudDrive 回调未返回访问凭证")
}
return token, nil
}
func (provider cloudDriveOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
return OAuthTokenResult{}, errUnsupportedOAuthOperation
}
// ─── 工具 ──────────────────────────────────────────────────────────────────────
func appendCallbackParams(rawURL string, params url.Values) (string, error) {
callbackURL, err := url.Parse(rawURL)
if err != nil || callbackURL.Scheme == "" || callbackURL.Host == "" {
return "", fmt.Errorf("OAuth 回跳地址无效")
}
if callbackURL.Fragment != "" {
fragmentPath, fragmentQuery, hasQuery := strings.Cut(callbackURL.Fragment, "?")
fragmentValues := url.Values{}
if hasQuery {
fragmentValues, err = url.ParseQuery(fragmentQuery)
if err != nil {
return "", err
}
}
for key, values := range params {
for _, value := range values {
if value != "" {
fragmentValues.Set(key, value)
}
}
}
callbackURL.Fragment = fragmentPath + "?" + fragmentValues.Encode()
return callbackURL.String(), nil
}
query := callbackURL.Query()
for key, values := range params {
for _, value := range values {
if value != "" {
query.Set(key, value)
}
}
}
callbackURL.RawQuery = query.Encode()
return callbackURL.String(), nil
}
func httpGetJSON(ctx context.Context, endpoint string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", DefaultUA)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("授权服务返回 HTTP %d:%s", resp.StatusCode, string(body))
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return nil, err
}
if nested, ok := data["data"].(map[string]any); ok {
for key, value := range nested {
if _, exists := data[key]; !exists {
data[key] = value
}
}
}
return data, nil
}
func tokenResultFromMap(data map[string]any) OAuthTokenResult {
if nested, ok := data["data"].(map[string]any); ok {
for key, value := range nested {
if _, exists := data[key]; !exists {
data[key] = value
}
}
}
expiresIn := int64(0)
switch value := data["expires_in"].(type) {
case float64:
expiresIn = int64(value)
case json.Number:
expiresIn, _ = value.Int64()
case string:
expiresIn, _ = strconv.ParseInt(value, 10, 64)
}
token := OAuthTokenResult{
AccessToken: stringField(data, "access_token"),
RefreshToken: stringField(data, "refresh_token"),
ExpiresIn: expiresIn,
}
token.Done = token.AccessToken != "" && token.RefreshToken != ""
return token
}
func stringField(data map[string]any, key string) string {
value, ok := data[key]
if !ok || value == nil {
return ""
}
switch v := value.(type) {
case string:
return v
case fmt.Stringer:
return v.String()
default:
return fmt.Sprint(v)
}
}
// ─── 中继回调 AES 加解密(与 QMediaSync 同算法:AES-256-CBC + URL-safe Base64) ───
// EncryptRelay 加密中继授权 state / 回调数据。
func EncryptRelay(plaintext string) (string, error) {
return encryptAES(plaintext, RelayEncryptionKey)
}
// DecryptRelay 解密中继回调数据。
func DecryptRelay(encrypted string) (string, error) {
return decryptAES(encrypted, RelayEncryptionKey)
}
func encryptAES(plaintext, keyText string) (string, error) {
if keyText == "" {
return "", errors.New("加密密钥不能为空(请配置 strm.115_relay_key)")
}
keyHash := sha256.Sum256([]byte(keyText))
block, err := aes.NewCipher(keyHash[:])
if err != nil {
return "", err
}
padding := aes.BlockSize - len(plaintext)%aes.BlockSize
padtext := append([]byte(plaintext), make([]byte, padding)...)
for i := 0; i < padding; i++ {
padtext[len(plaintext)+i] = byte(padding)
}
ciphertext := make([]byte, aes.BlockSize+len(padtext))
iv := ciphertext[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext[aes.BlockSize:], padtext)
encoded := base64.StdEncoding.EncodeToString(ciphertext)
encoded = strings.ReplaceAll(encoded, "+", "-")
encoded = strings.ReplaceAll(encoded, "/", "_")
return strings.TrimRight(encoded, "="), nil
}
func decryptAES(encrypted, keyText string) (string, error) {
if keyText == "" {
return "", errors.New("加密密钥不能为空(请配置 strm.115_relay_key)")
}
encrypted = strings.ReplaceAll(encrypted, "-", "+")
encrypted = strings.ReplaceAll(encrypted, "_", "/")
switch len(encrypted) % 4 {
case 2:
encrypted += "=="
case 3:
encrypted += "="
}
data, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
if len(data) < aes.BlockSize {
return "", errors.New("密文长度不足")
}
keyHash := sha256.Sum256([]byte(keyText))
block, err := aes.NewCipher(keyHash[:])
if err != nil {
return "", err
}
iv := data[:aes.BlockSize]
ciphertext := data[aes.BlockSize:]
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(ciphertext, ciphertext)
if len(ciphertext) == 0 {
return "", errors.New("解密结果为空")
}
padding := int(ciphertext[len(ciphertext)-1])
if padding <= 0 || padding > aes.BlockSize {
return "", errors.New("解密填充非法")
}
return string(ciphertext[:len(ciphertext)-padding]), nil
}
+326
View File
@@ -0,0 +1,326 @@
// 115 开放平台只读 API:列目录、详情、下载直链、授权(二维码/换 token/刷新)。
package cloud115
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// ─── 文件模型 ──────────────────────────────────────────────────────────────────
type FileType string
const (
TypeFile FileType = "1"
TypeDir FileType = "0"
)
// RemoteFile 是 115 文件列表中的一个条目。
type RemoteFile struct {
FileId string `json:"fid"` // 文件 ID
Pid string `json:"pid"` // 父文件夹 ID
Category FileType `json:"fc"` // 0 文件夹 1 文件
FileName string `json:"fn"` // 文件名
PickCode string `json:"pc"` // 提取码
Utime int64 `json:"upt"` // 修改时间
Ptime int64 `json:"uppt"` // 上传时间
Sha1 string `json:"sha1"` // SHA1
FileSize int64 `json:"fs"` // 大小
Fta string `json:"fta"` // 0/2 未上传完成,1 已完成
}
func (f RemoteFile) ModifiedAt() int64 {
if f.Utime > 0 {
return f.Utime
}
return f.Ptime
}
type FileListResp struct {
RespBase
Path []struct {
Name string `json:"name"`
FileId string `json:"cid"`
} `json:"path"`
PathStr string `json:"path_str"`
}
// GetFsList 列目录(cid=0 为根目录)。
func (c *OpenClient) GetFsList(ctx context.Context, cid string, offset, limit int) ([]RemoteFile, string, error) {
if cid == "" {
cid = "0"
}
params := map[string]string{"cid": cid}
if limit > 0 {
params["limit"] = fmt.Sprint(limit)
}
if offset > 0 {
params["offset"] = fmt.Sprint(offset)
}
params["cur"] = "1"
params["show_dir"] = "1"
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/ufile/files", params, 2)
if err != nil {
return nil, "", err
}
// state=false(token 过期、限流、业务失败等)绝不能当作空目录返回:
// 同步流程会据此认为远端已清空并清理本地 .strm/元数据文件。
if !resp.State {
return nil, "", NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 接口调用失败")
}
var list FileListResp
list.RespBase = *resp
if len(resp.Raw) > 0 {
_ = json.Unmarshal(resp.Raw, &list)
}
files, err := openList[RemoteFile](resp.Data)
if err != nil {
return nil, "", fmt.Errorf("115: 解析文件列表失败:%w", err)
}
pathStr := make([]string, 0, len(list.Path))
for _, item := range list.Path {
if item.FileId == "0" {
continue
}
pathStr = append(pathStr, item.Name)
}
return files, strings.Join(pathStr, "/"), nil
}
// GetFsDetailByCid 查询文件(夹)详情。
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
params := map[string]string{"file_id": fileId}
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/folder/get_info", params, 2)
if err != nil {
return nil, err
}
return openFirstList[RemoteFileDetail](resp.Data)
}
// RemoteFileDetail 是文件详情。
type RemoteFileDetail struct {
FileId string `json:"file_id"`
FileName string `json:"file_name"`
PickCode string `json:"pick_code"`
Sha1 string `json:"sha1"`
Category FileType `json:"file_category"`
SizeByte int64 `json:"size_byte"`
Paths []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
} `json:"paths"`
}
// ─── 下载直链 ──────────────────────────────────────────────────────────────────
type downloadURLData struct {
FileName string `json:"file_name"`
PickCode string `json:"pick_code"`
Sha1 string `json:"sha1"`
URL struct {
URL string `json:"url"`
} `json:"url"`
}
// GetDownloadURL 获取下载直链(pickcode)。
func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (string, error) {
params := map[string]string{"pick_code": pickCode}
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1)
if err != nil {
return "", err
}
var data map[string]downloadURLData
if err := json.Unmarshal(resp.Data, &data); err != nil {
return "", fmt.Errorf("115: 解析下载地址失败:%w", err)
}
first := firstOrEmpty(data)
if first.URL.URL == "" {
return "", fmt.Errorf("115: 下载地址为空(文件可能未上传完成或已被删除)")
}
return first.URL.URL, nil
}
// ─── 授权(设备码扫码) ──────────────────────────────────────────────────────
// QrCodeScanStatus 扫码状态。
type QrCodeScanStatus int
const (
QrCodeScanStatusExpired QrCodeScanStatus = 5
QrCodeScanStatusNotScanned QrCodeScanStatus = 2
QrCodeScanStatusScanned QrCodeScanStatus = 3
QrCodeScanStatusConfirmed QrCodeScanStatus = 4
)
func (s QrCodeScanStatus) String() string {
switch s {
case QrCodeScanStatusNotScanned:
return "waiting"
case QrCodeScanStatusScanned:
return "scanned"
case QrCodeScanStatusConfirmed:
return "confirmed"
default:
return "expired"
}
}
func (s QrCodeScanStatus) Tip() string {
switch s {
case QrCodeScanStatusNotScanned:
return "等待扫码"
case QrCodeScanStatusScanned:
return "已扫码,请在 115 客户端确认"
case QrCodeScanStatusConfirmed:
return "授权成功"
default:
return "二维码已过期"
}
}
// QrCodeData 是设备码二维码数据。
type QrCodeData struct {
Uid string `json:"uid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
Qrcode string `json:"qrcode"` // 二维码图片 URL
}
// QrCodeDataReturn 含 PKCE code_verifier。
type QrCodeDataReturn struct {
QrCodeData
CodeVerifier string `json:"code_verifier"`
}
// TokenData 是 115 访问令牌。
type TokenData struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
}
// GetQrCode 获取设备码登录二维码。
func (c *OpenClient) GetQrCode() (*QrCodeDataReturn, error) {
codeVerifier := RandomString(64)
params := map[string]string{
"client_id": c.AppID,
"code_challenge": genCodeChallenge(codeVerifier),
"code_challenge_method": "sha256",
}
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/authDeviceCode", params, false, 1)
if err != nil {
return nil, err
}
code, err := openFirstList[QrCodeData](resp.Data)
if err != nil {
return nil, err
}
return &QrCodeDataReturn{QrCodeData: *code, CodeVerifier: codeVerifier}, nil
}
// QrCodeScanStatus 查询扫码状态。
func (c *OpenClient) QrCodeScanStatus(codeData *QrCodeData) (QrCodeScanStatus, error) {
if codeData == nil {
return QrCodeScanStatusExpired, fmt.Errorf("空二维码数据")
}
params := map[string]string{
"uid": codeData.Uid,
"time": fmt.Sprint(codeData.Time),
"sign": codeData.Sign,
}
resp, err := c.doJSON(context.Background(), "GET", QRCodeAPIBase+"/get/status/", params, false, 1)
if err != nil {
return QrCodeScanStatusExpired, err
}
status, err := openFirstList[struct {
Status int `json:"status"` // 0 未扫码 1 已扫码 2 已确认
}](resp.Data)
if err != nil {
return QrCodeScanStatusExpired, err
}
switch status.Status {
case 1:
return QrCodeScanStatusScanned, nil
case 2:
return QrCodeScanStatusConfirmed, nil
case 0:
return QrCodeScanStatusNotScanned, nil
default:
return QrCodeScanStatusExpired, nil
}
}
// GetToken 用设备码换访问令牌。
func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
if qrCode == nil || qrCode.Uid == "" {
return nil, fmt.Errorf("空二维码数据")
}
params := map[string]string{
"uid": qrCode.Uid,
"code_verifier": qrCode.CodeVerifier,
}
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/deviceCodeToToken", params, false, 0)
if err != nil {
return nil, err
}
token, err := openFirstList[TokenData](resp.Data)
if err != nil {
return nil, err
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return token, nil
}
// RefreshToken 刷新访问令牌。
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
if refreshToken == "" {
refreshToken = c.RefreshTokenStr
}
if refreshToken == "" {
return nil, fmt.Errorf("没有可用的 refresh_token")
}
params := map[string]string{"refresh_token": refreshToken}
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/refreshToken", params, false, 0)
if err != nil && resp == nil {
return nil, err
}
if resp == nil {
return nil, err
}
if !resp.State {
apiErr := NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 开放平台刷新访问凭证失败")
if IsRefreshTokenDead(apiErr) {
c.SetAuthToken("", "")
}
return nil, apiErr
}
token, err := openFirstList[TokenData](resp.Data)
if err != nil {
return nil, err
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return token, nil
}
// ─── 用户信息 ──────────────────────────────────────────────────────────────────
// UserInfo 是 115 用户信息。
type UserInfo struct {
UserId json.Number `json:"user_id"`
UserName string `json:"user_name"`
}
// FetchUserInfo 获取用户信息。
func (c *OpenClient) FetchUserInfo(ctx context.Context) (*UserInfo, error) {
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/user/info", nil, 1)
if err != nil {
return nil, err
}
var info UserInfo
if err := json.Unmarshal(resp.Data, &info); err != nil {
return nil, fmt.Errorf("115: 解析用户信息失败:%w", err)
}
return &info, nil
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
package cloud115
import "math/rand"
const randCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// RandomString 生成指定长度的随机字符串(PKCE code_verifier 等)。
func RandomString(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = randCharset[rand.Intn(len(randCharset))]
}
return string(b)
}
+11 -2
View File
@@ -31,8 +31,8 @@ type Container struct {
Bangumi *BangumiProvider
TheTVDB *TheTVDBProvider
Fanart *FanartProvider
Scraper *ScraperService
Playback *PlaybackService
Scraper *ScraperService
Playback *PlaybackService
ImageProxy *ImageProxy
Watcher *WatcherService
Subtitle *SubtitleService
@@ -60,6 +60,7 @@ type Container struct {
Sessions *SessionTrackerService
RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService
Strm *StrmService
stopCtx context.Context
stopCancel context.CancelFunc
@@ -92,6 +93,11 @@ func (c *Container) Boot() {
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
// STRM 元数据下载/上传队列与定时同步巡检
if c.Strm != nil {
c.Strm.Start(c.stopCtx)
}
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil {
@@ -134,6 +140,9 @@ func (c *Container) Close() {
if c.Scheduler != nil {
c.Scheduler.Stop()
}
if c.Strm != nil {
c.Strm.Stop()
}
if c.Watcher != nil {
c.Watcher.Stop()
}
+2 -1
View File
@@ -67,7 +67,7 @@ func (b *serviceContainerBuilder) initProviderServices() {
b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos)
b.c.Danmaku = NewDanmakuService(b.log, b.repos)
adult := NewAdultProvider(b.log, b.c.APIConfig, b.repos)
adult := NewAdultProvider(b.log, b.c.APIConfig, b.repos)
b.c.Scraper = NewScraperService(
b.cfg, b.log, b.repos,
b.c.TMDb, b.c.Bangumi, b.c.TheTVDB, b.c.Fanart,
@@ -110,6 +110,7 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Subtitle = NewSubtitleService(b.log, b.repos)
b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos)
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
}
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
+421
View File
@@ -0,0 +1,421 @@
// 115 开放平台 OAuth 授权会话与 Token 维护。
//
// 授权方式(与 QMediaSync 一致):
// - built_in_appid:官方应用目录设备码扫码(无回调,轮询 qrcodeapi)
// - qmediasync / mqfamily:中继授权(浏览器授权后中继 POST 回本服务回调端点,
// 需配置 strm.115_relay_key 共享密钥解密)
// - moviepilot:MoviePilot 授权服务(轮询其 /u115/token)
// - clouddrive:CloudDrive 中转(115 授权页 → zhenyunpan 换 token 后回跳)
//
// 授权成功后将 access_token / refresh_token 写入网盘账号配置(加密存储)。
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
)
// strm115RelayKeySetting 是中继授权共享 AES 密钥的设置键。
const Strm115RelayKeySetting = "strm.115_relay_key"
const strm115AuthSessionTTL = 6 * time.Minute
// strm115AuthSession 是一次 115 授权会话(内存态,重启后需重新授权)。
type strm115AuthSession struct {
ID string
AccountID string
Source cloud115.Source
Mode string // qrcode / poll / callback
QrCode *cloud115.QrCodeDataReturn
State string
Provider cloud115.OAuthProvider
TokenCh chan *cloud115.OAuthTokenResult
CreatedAt time.Time
}
// Strm115AuthStartResult 是授权发起结果。
type Strm115AuthStartResult struct {
SessionID string `json:"session_id"`
Mode string `json:"mode"` // qrcode / url
AuthURL string `json:"auth_url,omitempty"`
State string `json:"state,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
QRCode *struct {
UID string `json:"uid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
Qrcode string `json:"qrcode"`
} `json:"qrcode,omitempty"`
}
// Strm115AuthStatus 是授权轮询结果。
type Strm115AuthStatus struct {
Status string `json:"status"` // waiting / scanned / confirmed / expired
Tip string `json:"tip"`
}
// List115Sources 返回可用的 115 授权来源列表。
func (s *StrmService) List115Sources(ctx context.Context) (map[string][]cloud115.Source, error) {
out := map[string][]cloud115.Source{}
out["built_in"] = cloud115.BuiltInAppIDSources()
out["relay"] = cloud115.BuiltInRelaySources()
out["third_party"] = cloud115.ThirdPartySources()
return out, nil
}
// Start115OAuth 发起 115 授权;redirectURL 为空时使用服务自身地址。
func (s *StrmService) Start115OAuth(ctx context.Context, accountID, authSource, appID, provider, redirectURL string) (*Strm115AuthStartResult, error) {
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil || acct == nil {
return nil, errNotFoundOr(err, "网盘账号不存在")
}
if acct.Provider != model.StrmProvider115 {
return nil, errors.New("该账号不是 115 网盘账号")
}
source, err := resolve115Source(authSource, provider, appID)
if err != nil {
return nil, err
}
sessionID := newStrmID()
session := &strm115AuthSession{
ID: sessionID,
AccountID: accountID,
Source: source,
TokenCh: make(chan *cloud115.OAuthTokenResult, 1),
CreatedAt: time.Now(),
}
result := &Strm115AuthStartResult{SessionID: sessionID}
switch authSource {
case string(cloud115.SourceTypeBuiltInAppID), string(cloud115.SourceTypeCustomAppID):
// 官方设备码扫码
client := cloud115.NewOpenClient(source.AppID, "", "")
qr, err := client.GetQrCode()
if err != nil {
return nil, fmt.Errorf("获取 115 授权二维码失败:%w", err)
}
session.Mode = "qrcode"
session.QrCode = qr
session.Provider, _ = cloud115.GetOAuthProvider(source)
result.Mode = "qrcode"
result.ExpiresIn = 300
result.QRCode = &struct {
UID string `json:"uid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
Qrcode string `json:"qrcode"`
}{UID: qr.Uid, Time: qr.Time, Sign: qr.Sign, Qrcode: qr.Qrcode}
default:
// 中继 / MoviePilot / CloudDrive 网页授权
oauthProvider, err := cloud115.GetOAuthProvider(source)
if err != nil {
return nil, err
}
oauthResult, err := oauthProvider.BuildAuth(ctx, cloud115.OAuthURLRequest{
Source: source,
RedirectURL: redirectURL,
AuthorizationID: sessionID,
})
if err != nil {
return nil, fmt.Errorf("发起 115 授权失败:%w", err)
}
session.Provider = oauthProvider
session.State = oauthResult.State
if oauthResult.Polling {
session.Mode = "poll"
} else {
session.Mode = "callback"
}
result.Mode = "url"
result.AuthURL = oauthResult.AuthURL
result.State = oauthResult.State
result.ExpiresIn = oauthResult.ExpiresIn
if result.ExpiresIn == 0 {
result.ExpiresIn = 300
}
}
s.mu.Lock()
if s.oauthSessions == nil {
s.oauthSessions = map[string]*strm115AuthSession{}
}
s.oauthSessions[sessionID] = session
s.mu.Unlock()
s.sweep115AuthSessions()
return result, nil
}
// Poll115OAuth 轮询授权状态;确认后把 token 写入账号。
func (s *StrmService) Poll115OAuth(ctx context.Context, sessionID string) (*Strm115AuthStatus, error) {
s.mu.Lock()
session := s.oauthSessions[sessionID]
s.mu.Unlock()
if session == nil {
return nil, errors.New("授权会话不存在或已过期,请重新发起")
}
if time.Since(session.CreatedAt) > strm115AuthSessionTTL {
s.drop115AuthSession(sessionID)
return &Strm115AuthStatus{Status: "expired", Tip: "授权会话已过期"}, nil
}
var status cloud115.QrCodeScanStatus
var token *cloud115.OAuthTokenResult
switch session.Mode {
case "qrcode":
client := cloud115.NewOpenClient(session.Source.AppID, "", "")
st, err := client.QrCodeScanStatus(&session.QrCode.QrCodeData)
if err != nil {
return nil, fmt.Errorf("查询扫码状态失败:%w", err)
}
status = st
if st == cloud115.QrCodeScanStatusConfirmed {
t, err := client.GetToken(session.QrCode)
if err != nil {
return nil, fmt.Errorf("获取访问令牌失败:%w", err)
}
token = &cloud115.OAuthTokenResult{
AccessToken: t.AccessToken, RefreshToken: t.RefreshToken, ExpiresIn: t.ExpiresIn, Done: true,
}
}
case "poll":
if session.Provider == nil {
return nil, errors.New("授权服务不可用")
}
t, err := session.Provider.Poll(ctx, session.State)
if err != nil {
return nil, err
}
if t.Done {
token = &t
} else {
return &Strm115AuthStatus{Status: "waiting", Tip: "等待授权确认"}, nil
}
case "callback":
select {
case t := <-session.TokenCh:
if t == nil || !t.Done {
return &Strm115AuthStatus{Status: "waiting", Tip: "等待授权确认"}, nil
}
token = t
default:
return &Strm115AuthStatus{Status: "waiting", Tip: "正在等待网页授权完成(5 分钟内)"}, nil
}
default:
return nil, fmt.Errorf("未知的授权模式:%s", session.Mode)
}
switch status {
case cloud115.QrCodeScanStatusNotScanned:
return &Strm115AuthStatus{Status: "waiting", Tip: "等待扫码"}, nil
case cloud115.QrCodeScanStatusScanned:
return &Strm115AuthStatus{Status: "scanned", Tip: "已扫码,请在 115 客户端确认"}, nil
case cloud115.QrCodeScanStatusExpired:
s.drop115AuthSession(sessionID)
return &Strm115AuthStatus{Status: "expired", Tip: "二维码已过期"}, nil
}
if token == nil || !token.Done {
return &Strm115AuthStatus{Status: "waiting", Tip: "授权处理中"}, nil
}
if err := s.save115OAuthToken(ctx, session, token); err != nil {
return nil, err
}
s.drop115AuthSession(sessionID)
return &Strm115AuthStatus{Status: "confirmed", Tip: "授权成功"}, nil
}
// Handle115OAuthCallback 处理中继/CloudDrive 授权回跳(公开端点)。
func (s *StrmService) Handle115OAuthCallback(ctx context.Context, payload map[string]string) error {
authID := strings.TrimSpace(payload["authorization_id"])
if authID == "" {
return errors.New("缺少 authorization_id")
}
s.mu.Lock()
session := s.oauthSessions[authID]
s.mu.Unlock()
if session == nil {
return errors.New("授权会话不存在或已过期")
}
if session.Provider == nil {
return errors.New("该授权会话不支持回调")
}
token, err := session.Provider.Confirm(ctx, payload)
if err != nil {
return err
}
if !token.Done {
return errors.New("回调未包含有效访问凭证")
}
select {
case session.TokenCh <- &token:
default:
}
return nil
}
// save115OAuthToken 把授权 token 写入账号配置并附带用户信息。
func (s *StrmService) save115OAuthToken(ctx context.Context, session *strm115AuthSession, token *cloud115.OAuthTokenResult) error {
acct, err := s.repo.StrmAccount.FindByID(ctx, session.AccountID)
if err != nil || acct == nil {
return errNotFoundOr(err, "网盘账号不存在")
}
cfg, err := s.strmAccountConfig(acct)
if err != nil {
return err
}
cfg["app_id"] = session.Source.AppID
cfg["access_token"] = s.crypto.Encrypt(token.AccessToken)
cfg["refresh_token"] = s.crypto.Encrypt(token.RefreshToken)
// 尝试补充用户信息(失败不阻塞授权)
if client := cloud115.NewOpenClient(session.Source.AppID, token.AccessToken, token.RefreshToken); client != nil {
if info, err := client.FetchUserInfo(ctx); err == nil && info != nil {
cfg["user_id"] = info.UserId.String()
cfg["user_name"] = info.UserName
if strings.TrimSpace(acct.Name) == "" || strings.HasPrefix(acct.Name, "115") || acct.Name == providerLabel(model.StrmProvider115) {
acct.Name = firstNonEmpty(info.UserName, acct.Name)
}
}
}
enc, err := s.strmAccountConfigJSON(cfg, false)
if err != nil {
return err
}
acct.Config = enc
now := time.Now()
acct.LastTestAt = &now
acct.LastTestResult = "授权成功"
acct.LastTestOK = true
return s.repo.StrmAccount.Update(ctx, acct)
}
func (s *StrmService) drop115AuthSession(sessionID string) {
s.mu.Lock()
delete(s.oauthSessions, sessionID)
s.mu.Unlock()
}
func (s *StrmService) sweep115AuthSessions() {
s.mu.Lock()
defer s.mu.Unlock()
for id, session := range s.oauthSessions {
if time.Since(session.CreatedAt) > strm115AuthSessionTTL {
delete(s.oauthSessions, id)
}
}
}
// resolve115Source 解析授权来源。
func resolve115Source(authSource, provider, appID string) (cloud115.Source, error) {
authSource = strings.TrimSpace(authSource)
appID = strings.TrimSpace(appID)
switch authSource {
case string(cloud115.SourceTypeBuiltInAppID):
if source, ok := cloud115.FindSource(cloud115.SourceTypeBuiltInAppID, cloud115.ProviderOfficialPKCE, appID); ok {
return source, nil
}
return cloud115.Source{}, fmt.Errorf("未知的内置应用 ID:%s", appID)
case string(cloud115.SourceTypeCustomAppID):
if appID == "" {
return cloud115.Source{}, errors.New("自定义 APP ID 不能为空")
}
return cloud115.Source{SourceType: cloud115.SourceTypeCustomAppID, Provider: cloud115.ProviderOfficialPKCE, AppID: appID, AppName: cloud115.CustomAppName, DisplayName: cloud115.CustomAppName}, nil
case string(cloud115.SourceTypeBuiltInRelay):
if source, ok := cloud115.FindSource(cloud115.SourceTypeBuiltInRelay, cloud115.AuthProvider(provider), appID); ok {
return source, nil
}
return cloud115.Source{}, errors.New("未知的中继授权服务")
case string(cloud115.SourceTypeThirdPartyService):
if source, ok := cloud115.FindSource(cloud115.SourceTypeThirdPartyService, cloud115.AuthProvider(provider), appID); ok {
return source, nil
}
return cloud115.Source{}, errors.New("未知的第三方授权服务")
default:
return cloud115.Source{}, errors.New("不支持的授权来源")
}
}
// refresh115TokensLoop 定期刷新 115 开放平台访问令牌。
func (s *StrmService) refresh115TokensLoop(ctx context.Context) {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-ticker.C:
s.refresh115TokensOnce(ctx)
}
}
}
func (s *StrmService) refresh115TokensOnce(ctx context.Context) {
accounts, err := s.repo.StrmAccount.List(ctx)
if err != nil {
return
}
for i := range accounts {
acct := &accounts[i]
if acct.Provider != model.StrmProvider115 || !acct.Enabled {
continue
}
cfg, err := s.strmAccountConfig(acct)
if err != nil || cfg["access_token"] == "" || cfg["refresh_token"] == "" {
continue
}
client := cloud115.NewOpenClient(cfg["app_id"], cfg["access_token"], cfg["refresh_token"])
token, err := client.RefreshToken(cfg["refresh_token"])
if err != nil {
msg := "令牌刷新失败:" + err.Error()
if cloud115.IsRefreshTokenDead(err) {
msg = "授权已失效,请重新授权:" + err.Error()
cfg["access_token"] = ""
}
now := time.Now()
acct.LastTestAt = &now
acct.LastTestResult = msg
acct.LastTestOK = false
if cfg["access_token"] == "" {
enc, encErr := s.strmAccountConfigJSON(cfg, false)
if encErr == nil {
acct.Config = enc
}
}
_ = s.repo.StrmAccount.Update(ctx, acct)
s.log.Warn("115 token refresh failed", zap.String("account", acct.Name), zap.String("error", msg))
continue
}
cfg["access_token"] = s.crypto.Encrypt(token.AccessToken)
cfg["refresh_token"] = s.crypto.Encrypt(token.RefreshToken)
enc, err := s.strmAccountConfigJSON(cfg, false)
if err != nil {
continue
}
acct.Config = enc
now := time.Now()
acct.LastTestAt = &now
acct.LastTestResult = "ok"
acct.LastTestOK = true
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
s.log.Warn("update 115 token failed", zap.Error(err))
}
}
}
// sync115RelayKey 把设置里的中继密钥同步给 cloud115(启动与设置保存时调用)。
func (s *StrmService) sync115RelayKey(ctx context.Context) {
cloud115.RelayEncryptionKey = s.strmSetting(ctx, Strm115RelayKeySetting)
}
// newStrmID 生成授权会话 ID。
func newStrmID() string {
return "auth-" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "") + "-" + cloud115.RandomString(8)
}
+228
View File
@@ -0,0 +1,228 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
)
// Test115OAuthQRFlow 用 mock 的 115 开放平台接口走完设备码扫码授权:
// 创建账号(空凭据)→ 发起授权(取二维码)→ 轮询(未扫码/已扫码/已确认)
// → 确认后 token 写入账号 → 驱动可用。
func Test115OAuthQRFlow(t *testing.T) {
svc := testStrmService(t)
var scanCalls int
pro := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/user/info":
w.Write([]byte(`{"state":true,"data":{"user_id":"115001","user_name":"测试用户"}}`))
default:
t.Errorf("unexpected pro path %s", r.URL.Path)
}
}))
defer pro.Close()
passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/authDeviceCode":
w.Write([]byte(`{"state":true,"data":{"uid":"U1","time":1700,"sign":"S1","qrcode":"https://img/qr.png"}}`))
case "/open/deviceCodeToToken":
w.Write([]byte(`{"state":true,"data":{"access_token":"open-at","refresh_token":"open-rt","expires_in":7200}}`))
default:
t.Errorf("unexpected passport path %s", r.URL.Path)
}
}))
defer passport.Close()
qr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scanCalls++
switch scanCalls {
case 1:
w.Write([]byte(`{"state":true,"data":{"status":0}}`))
case 2:
w.Write([]byte(`{"state":true,"data":{"status":1}}`))
default:
w.Write([]byte(`{"state":true,"data":{"status":2}}`))
}
}))
defer qr.Close()
oldPro, oldPassport, oldQR := cloud115.ProAPIBase, cloud115.PassportAPIBase, cloud115.QRCodeAPIBase
cloud115.ProAPIBase, cloud115.PassportAPIBase, cloud115.QRCodeAPIBase = pro.URL, passport.URL, qr.URL
defer func() {
cloud115.ProAPIBase, cloud115.PassportAPIBase, cloud115.QRCodeAPIBase = oldPro, oldPassport, oldQR
}()
// 1. 创建 115 账号(自定义 AppID,空凭据)
acct, err := svc.CreateStrmAccount(context.Background(), "我的115", model.StrmProvider115, map[string]string{})
if err != nil {
t.Fatalf("create account: %v", err)
}
if HasStrmAccountCredential(acct) {
t.Fatalf("new account should have no credential")
}
// 2. 发起设备码授权
result, err := svc.Start115OAuth(context.Background(), acct.ID,
string(cloud115.SourceTypeCustomAppID), "100195125", "", "")
if err != nil {
t.Fatalf("start oauth: %v", err)
}
if result.Mode != "qrcode" || result.QRCode == nil || result.QRCode.Qrcode == "" {
t.Fatalf("bad start result: %#v", result)
}
// 3. 轮询:waiting -> scanned
for _, want := range []string{"waiting", "scanned"} {
status, err := svc.Poll115OAuth(context.Background(), result.SessionID)
if err != nil {
t.Fatalf("poll: %v", err)
}
if status.Status != want {
t.Fatalf("poll status = %s, want %s", status.Status, want)
}
}
// 4. 确认
status, err := svc.Poll115OAuth(context.Background(), result.SessionID)
if err != nil {
t.Fatalf("poll confirm: %v", err)
}
if status.Status != "confirmed" {
t.Fatalf("final status = %s, want confirmed", status.Status)
}
// 5. 账号已保存 token 与用户信息
updated, err := svc.repo.StrmAccount.FindByID(context.Background(), acct.ID)
if err != nil || updated == nil {
t.Fatal("account missing")
}
cfg, err := svc.strmAccountConfig(updated)
if err != nil {
t.Fatal(err)
}
if cfg["access_token"] != "open-at" || cfg["refresh_token"] != "open-rt" {
t.Fatalf("token not saved: %#v", cfg)
}
if cfg["user_name"] != "测试用户" && !strings.Contains(updated.Name, "115") && updated.Name != "测试用户" {
t.Fatalf("user info not saved: %#v", cfg)
}
if !HasStrmAccountCredential(updated) {
t.Fatal("credential should be present after auth")
}
}
// Test115OAUTHCallbackRelay 中继回调链路:发起 → 回调(加密 payload)→ 轮询确认。
func Test115OAUTHCallbackRelay(t *testing.T) {
svc := testStrmService(t)
cloud115.RelayEncryptionKey = "callback-test-key"
defer func() { cloud115.RelayEncryptionKey = "" }()
acct, err := svc.CreateStrmAccount(context.Background(), "中继115", model.StrmProvider115, map[string]string{})
if err != nil {
t.Fatal(err)
}
result, err := svc.Start115OAuth(context.Background(), acct.ID,
string(cloud115.SourceTypeBuiltInRelay), "QMediaSync", string(cloud115.ProviderQMediaSync),
"http://127.0.0.1/api/strm/oauth/callback")
if err != nil {
t.Fatalf("start relay oauth: %v", err)
}
if result.Mode != "url" || result.AuthURL == "" {
t.Fatalf("bad relay start: %#v", result)
}
if !strings.Contains(result.AuthURL, "oauth.qmediasync.cn") {
t.Fatalf("unexpected relay url: %s", result.AuthURL)
}
// 模拟中继服务器回调(加密 data + authorization_id)
payload := `{"data":{"access_token":"relay-at","refresh_token":"relay-rt","expires_in":7200}}`
encrypted, err := cloud115.EncryptRelay(payload)
if err != nil {
t.Fatal(err)
}
cbErr := svc.Handle115OAuthCallback(context.Background(), map[string]string{
"authorization_id": result.SessionID,
"data": encrypted,
})
if cbErr != nil {
t.Fatalf("callback: %v", cbErr)
}
// 轮询确认
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
status, err := svc.Poll115OAuth(context.Background(), result.SessionID)
if err != nil {
t.Fatalf("poll: %v", err)
}
if status.Status == "confirmed" {
break
}
time.Sleep(20 * time.Millisecond)
}
updated, err := svc.repo.StrmAccount.FindByID(context.Background(), acct.ID)
if err != nil || updated == nil {
t.Fatal("account missing")
}
cfg, _ := svc.strmAccountConfig(updated)
if cfg["access_token"] != "relay-at" {
t.Fatalf("relay token not saved: %#v", cfg)
}
}
// Test115TokenRefreshLoop 令牌刷新与失效处理。
func Test115TokenRefreshLoop(t *testing.T) {
svc := testStrmService(t)
var refreshCalls int
passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/refreshToken" {
t.Errorf("unexpected path %s", r.URL.Path)
}
refreshCalls++
if strings.Contains(r.PostFormValue("refresh_token"), "dead") {
w.Write([]byte(`{"state":false,"code":40140119,"message":"已过期"}`))
return
}
w.Write([]byte(`{"state":true,"data":{"access_token":"new-at","refresh_token":"new-rt","expires_in":7200}}`))
}))
defer passport.Close()
old := cloud115.PassportAPIBase
cloud115.PassportAPIBase = passport.URL
defer func() { cloud115.PassportAPIBase = old }()
cfg := map[string]string{
"app_id": "100195125", "access_token": "at1", "refresh_token": "rt1",
}
acct, err := svc.CreateStrmAccount(context.Background(), "刷新测试", model.StrmProvider115, cfg)
if err != nil {
t.Fatal(err)
}
svc.refresh115TokensOnce(context.Background())
updated, _ := svc.repo.StrmAccount.FindByID(context.Background(), acct.ID)
updatedCfg, _ := svc.strmAccountConfig(updated)
if updatedCfg["access_token"] != "new-at" || updatedCfg["refresh_token"] != "new-rt" {
t.Fatalf("token not refreshed: %#v", updatedCfg)
}
// 失效的 refresh_token:access_token 清空 + 标记失败
deadCfg := map[string]string{"app_id": "100195125", "access_token": "at-d", "refresh_token": "rt-dead"}
deadAcct, err := svc.CreateStrmAccount(context.Background(), "失效测试", model.StrmProvider115, deadCfg)
if err != nil {
t.Fatal(err)
}
svc.refresh115TokensOnce(context.Background())
deadUpdated, _ := svc.repo.StrmAccount.FindByID(context.Background(), deadAcct.ID)
deadUpdatedCfg, _ := svc.strmAccountConfig(deadUpdated)
if deadUpdatedCfg["access_token"] != "" {
t.Fatalf("dead account access_token should be cleared: %#v", deadUpdatedCfg)
}
if deadUpdated.LastTestOK {
t.Fatal("dead account should be marked failed")
}
}
+143
View File
@@ -0,0 +1,143 @@
// STRM 播放端点解析:strm 文件内容指向 /api/strm/play/{provider},服务端
// 依据账号凭据解析出直链:能 302 的走 302(115/OpenList API),需要携带请求
// 头(CloudDrive2 WebDAV)的走反向代理;本地源直接以静态文件方式提供。
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
)
// ErrStrmPlayNotFound 表示 strm 播放目标不存在(handler 返回 404)。
var ErrStrmPlayNotFound = errors.New("strm play target not found")
// StrmPlayResult 是播放解析结果。
type StrmPlayResult struct {
// RedirectURL 非空时 handler 直接 302 到该地址。
RedirectURL string
// LocalPath 非空时 handler 以静态文件方式提供(本地源)。
LocalPath string
// Link 非空且 Proxy 为 true 时 handler 反向代理该直链。
Link *cloud.DirectLink
Proxy bool
}
// ResolvePlay 解析 strm 播放请求。
func (s *StrmService) ResolvePlay(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error) {
switch provider {
case model.StrmProviderLocal:
return s.resolveLocalPlay(ctx, q.Get("path"))
case model.StrmProvider115:
return s.resolveCloudPlay(ctx, provider, q, "pickcode")
case model.StrmProviderCloudDrive, model.StrmProviderOpenList:
return s.resolveCloudPlay(ctx, provider, q, "ref")
default:
return nil, errors.New("未知的 STRM 提供方")
}
}
func (s *StrmService) resolveCloudPlay(ctx context.Context, provider string, q url.Values, refKey string) (*StrmPlayResult, error) {
acctID := q.Get("acct")
ref := q.Get(refKey)
if acctID == "" || ref == "" {
return nil, fmt.Errorf("缺少 %s 参数", refKey)
}
acct, err := s.repo.StrmAccount.FindByID(ctx, acctID)
if err != nil || acct == nil {
return nil, errors.New("网盘账号不存在")
}
if !acct.Enabled {
return nil, errors.New("网盘账号已禁用")
}
if acct.Provider != provider {
return nil, errors.New("网盘账号类型不匹配")
}
p, err := s.providerFor(ctx, acct)
if err != nil {
return nil, err
}
link, err := p.Resolve(ctx, ref)
if err != nil {
return nil, err
}
if link == nil || link.URL == "" {
return nil, errors.New("解析播放地址失败")
}
if link.Proxy {
return &StrmPlayResult{Link: link, Proxy: true}, nil
}
return &StrmPlayResult{RedirectURL: link.URL}, nil
}
// resolveLocalPlay 本地源:校验路径位于某个本地同步目录的源目录内。
func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*StrmPlayResult, error) {
if rawPath == "" {
return nil, errors.New("缺少 path 参数")
}
target := filepath.Clean(rawPath)
info, err := os.Stat(target)
if err != nil || info.IsDir() {
return nil, ErrStrmPlayNotFound
}
paths, err := s.repo.StrmSyncPath.List(ctx)
if err != nil {
return nil, err
}
for i := range paths {
p := &paths[i]
if p.Provider != model.StrmProviderLocal || !p.Enabled || strings.TrimSpace(p.RemotePath) == "" {
continue
}
root := filepath.Clean(p.RemotePath)
if target == root || strings.HasPrefix(target, root+string(filepath.Separator)) {
return &StrmPlayResult{LocalPath: target}, nil
}
}
return nil, errors.New("文件不在任何本地同步目录内")
}
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
if link == nil || link.URL == "" {
return errors.New("空直链")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
if err != nil {
return err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
}
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := s.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
if value := resp.Header.Get(header); value != "" {
w.Header().Set(header, value)
}
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(resp.StatusCode)
}
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
_, _ = io.Copy(w, resp.Body)
}
return nil
}
+499
View File
@@ -0,0 +1,499 @@
// STRM 元数据下载/上传队列 worker。
//
// 下载队列:远端网盘 → 本地输出目录(nfo/图片/字幕);上传队列:本地 → 远端。
// 任务持久化在 DB,worker 轮询认领;失败按指数退避重试,超过上限标记 failed。
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
)
const (
strmMaxTaskRetry = 3
)
// downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。
func (s *StrmService) downloadWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
default:
}
tasks, err := s.repo.StrmDownload.ClaimPendingDownload(ctx, 1)
if err != nil {
s.log.Warn("claim strm download task failed", zap.Error(err))
sleepContext(ctx, 3*time.Second)
continue
}
if len(tasks) == 0 {
sleepContext(ctx, 2*time.Second)
continue
}
for i := range tasks {
s.processDownloadTask(ctx, &tasks[i])
}
}
}
func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmDownloadTask) {
finish := func(status, message string) {
now := time.Now()
task.Status = status
task.Error = message
task.FinishedAt = &now
if err := s.repo.StrmDownload.Update(context.Background(), task); err != nil {
s.log.Warn("update strm download task failed", zap.Error(err))
}
}
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
if err != nil || acct == nil {
finish(model.StrmTaskFailed, "网盘账号不存在")
return
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
s.downloadTaskFailWithRetry(task, err.Error())
return
}
link, err := provider.Resolve(ctx, task.RemoteRef)
if err != nil {
s.downloadTaskFailWithRetry(task, "解析下载地址失败:"+err.Error())
return
}
if err := downloadToFile(ctx, link, task.LocalPath, s.http); err != nil {
s.downloadTaskFailWithRetry(task, "下载失败:"+err.Error())
return
}
finish(model.StrmTaskDone, "")
}
// uploadWorker 上传队列 worker:认领 → WebDAV/OpenList 上传 → 收尾。
func (s *StrmService) uploadWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
default:
}
tasks, err := s.repo.StrmUpload.ClaimPendingUpload(ctx, 1)
if err != nil {
s.log.Warn("claim strm upload task failed", zap.Error(err))
sleepContext(ctx, 3*time.Second)
continue
}
if len(tasks) == 0 {
sleepContext(ctx, 2*time.Second)
continue
}
for i := range tasks {
s.processUploadTask(ctx, &tasks[i])
}
}
}
func (s *StrmService) processUploadTask(ctx context.Context, task *model.StrmUploadTask) {
finish := func(status, message string) {
now := time.Now()
task.Status = status
task.Error = message
task.FinishedAt = &now
if err := s.repo.StrmUpload.Update(context.Background(), task); err != nil {
s.log.Warn("update strm upload task failed", zap.Error(err))
}
}
if task.Provider == model.StrmProvider115 {
finish(model.StrmTaskFailed, "115 网盘暂不支持元数据上传")
return
}
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
if err != nil || acct == nil {
finish(model.StrmTaskFailed, "网盘账号不存在")
return
}
cfg, err := s.strmAccountConfig(acct)
if err == nil && task.Provider == model.StrmProviderOpenList && cfg["token"] == "" && cfg["password"] == "" {
finish(model.StrmTaskFailed, "OpenList 账号需要配置 Token 或密码才能上传")
return
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
s.uploadTaskFailWithRetry(task, err.Error())
return
}
putter, ok := provider.(interface {
PutFile(ctx context.Context, remotePath string, r io.Reader) error
})
if !ok {
finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
return
}
f, err := os.Open(task.LocalPath)
if err != nil {
s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error())
return
}
if err := putter.PutFile(ctx, task.RemotePath, f); err != nil {
_ = f.Close()
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
return
}
_ = f.Close()
finish(model.StrmTaskDone, "")
}
// downloadTaskFailWithRetry 下载失败任务按退避重试,超过上限标记 failed。
func (s *StrmService) downloadTaskFailWithRetry(task *model.StrmDownloadTask, message string) {
if !retryTask(&task.RetryCount, &task.Status, &task.Error, &task.NextTryAt, &task.FinishedAt, message) {
return
}
_ = s.repo.StrmDownload.Update(context.Background(), task)
}
// uploadTaskFailWithRetry 上传失败任务按退避重试,超过上限标记 failed。
func (s *StrmService) uploadTaskFailWithRetry(task *model.StrmUploadTask, message string) {
if !retryTask(&task.RetryCount, &task.Status, &task.Error, &task.NextTryAt, &task.FinishedAt, message) {
return
}
_ = s.repo.StrmUpload.Update(context.Background(), task)
}
// retryTask 失败状态机:重试次数不足则回 pending 并设置退避时间,否则 failed。
// 返回 false 表示无需再次落库(每次都会通过 Update 落库,因此恒返回 true)。
func retryTask(retryCount *int, status *string, errMsg *string, nextTryAt **time.Time, finishedAt **time.Time, message string) bool {
now := time.Now()
if *retryCount >= strmMaxTaskRetry {
*status = model.StrmTaskFailed
*errMsg = message
*finishedAt = &now
return true
}
*retryCount++
*status = model.StrmTaskPending
*errMsg = message
next := now.Add(time.Duration(*retryCount) * 30 * time.Second)
*nextTryAt = &next
*finishedAt = nil
return true
}
// downloadToFile 把直链内容下载到目标文件(临时文件 + 原子改名)。
func downloadToFile(ctx context.Context, link *cloud.DirectLink, target string, client *http.Client) error {
if link == nil || link.URL == "" {
return errors.New("空下载地址")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
if err != nil {
return err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
}
// 115 的 CDN 直链要求与换取链接时相同的浏览器 UA,否则返回 403;
// 其他网盘(WebDAV/OpenList)对该值不敏感,统一兜底设置。
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", cloud115.DefaultUA)
}
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http %d", resp.StatusCode)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
tmp := target + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return err
}
_, copyErr := io.Copy(out, resp.Body)
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
return os.Rename(tmp, target)
}
// queueCleanupLoop 定期清理 7 天前的完成/失败/取消任务。
func (s *StrmService) queueCleanupLoop(ctx context.Context) {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-ticker.C:
before := time.Now().AddDate(0, 0, -7)
if err := s.repo.StrmDownload.DeleteFinishedOlderThan(ctx, before); err != nil {
s.log.Warn("clean old strm download tasks failed", zap.Error(err))
}
if err := s.repo.StrmUpload.DeleteFinishedOlderThan(ctx, before); err != nil {
s.log.Warn("clean old strm upload tasks failed", zap.Error(err))
}
}
}
}
// ─── 队列查询与操作(handler 使用) ─────────────────────────────────────────────
// StrmQueueCounts 队列统计。
type StrmQueueCounts struct {
Pending int64 `json:"pending"`
Running int64 `json:"running"`
Done int64 `json:"done"`
Failed int64 `json:"failed"`
Canceled int64 `json:"canceled"`
}
// StrmQueueSnapshot 队列快照(统计 + 任务明细,分页)。
type StrmQueueSnapshot struct {
Counts StrmQueueCounts `json:"counts"`
Tasks []strmTaskView `json:"tasks"`
Total int64 `json:"total"` // 当前过滤条件下任务总数
Page int `json:"page"` // 当前页码(从 1 开始)
PageSize int `json:"page_size"` // 单页大小
}
// strmTaskView 队列任务统一视图(下载/上传共用)。
type strmTaskView struct {
ID string `json:"id"`
Kind string `json:"kind"` // download / upload
SyncPathID string `json:"sync_path_id"`
AccountID string `json:"account_id"`
Provider string `json:"provider"`
FileName string `json:"file_name"`
LocalPath string `json:"local_path"`
RemotePath string `json:"remote_path"`
Size int64 `json:"size"`
Status string `json:"status"`
Error string `json:"error"`
RetryCount int `json:"retry_count"`
CreatedAt string `json:"created_at"`
StartedAt *string `json:"started_at"`
FinishedAt *string `json:"finished_at"`
}
// DownloadQueueSnapshot 下载队列快照(分页)。
func (s *StrmService) DownloadQueueSnapshot(ctx context.Context, status string, page, pageSize int) (*StrmQueueSnapshot, error) {
page, pageSize = normalizeStrmTaskPage(page, pageSize)
tasks, total, err := s.repo.StrmDownload.List(ctx, status, page, pageSize)
if err != nil {
return nil, err
}
counts, err := s.repo.StrmDownload.CountByStatus(ctx)
if err != nil {
return nil, err
}
snap := &StrmQueueSnapshot{Counts: strmTaskCountsFrom(counts), Tasks: make([]strmTaskView, 0, len(tasks)), Total: total, Page: page, PageSize: pageSize}
for i := range tasks {
t := &tasks[i]
snap.Tasks = append(snap.Tasks, strmTaskView{
ID: t.ID,
Kind: "download",
SyncPathID: t.SyncPathID,
AccountID: t.AccountID,
Provider: t.Provider,
FileName: t.FileName,
LocalPath: t.LocalPath,
RemotePath: t.RemoteDir + "/" + t.FileName,
Size: t.Size,
Status: t.Status,
Error: t.Error,
RetryCount: t.RetryCount,
CreatedAt: t.CreatedAt.Local().Format(time.RFC3339),
StartedAt: timePtrString(t.StartedAt),
FinishedAt: timePtrString(t.FinishedAt),
})
}
return snap, nil
}
// UploadQueueSnapshot 上传队列快照(分页)。
func (s *StrmService) UploadQueueSnapshot(ctx context.Context, status string, page, pageSize int) (*StrmQueueSnapshot, error) {
page, pageSize = normalizeStrmTaskPage(page, pageSize)
tasks, total, err := s.repo.StrmUpload.List(ctx, status, page, pageSize)
if err != nil {
return nil, err
}
counts, err := s.repo.StrmUpload.CountByStatus(ctx)
if err != nil {
return nil, err
}
snap := &StrmQueueSnapshot{Counts: strmTaskCountsFrom(counts), Tasks: make([]strmTaskView, 0, len(tasks)), Total: total, Page: page, PageSize: pageSize}
for i := range tasks {
t := &tasks[i]
snap.Tasks = append(snap.Tasks, strmTaskView{
ID: t.ID,
Kind: "upload",
SyncPathID: t.SyncPathID,
AccountID: t.AccountID,
Provider: t.Provider,
FileName: t.FileName,
LocalPath: t.LocalPath,
RemotePath: t.RemotePath,
Size: t.Size,
Status: t.Status,
Error: t.Error,
RetryCount: t.RetryCount,
CreatedAt: t.CreatedAt.Local().Format(time.RFC3339),
StartedAt: timePtrString(t.StartedAt),
FinishedAt: timePtrString(t.FinishedAt),
})
}
return snap, nil
}
func strmTaskCountsFrom(m map[string]int64) StrmQueueCounts {
return StrmQueueCounts{
Pending: m[model.StrmTaskPending],
Running: m[model.StrmTaskRunning],
Done: m[model.StrmTaskDone],
Failed: m[model.StrmTaskFailed],
Canceled: m[model.StrmTaskCanceled],
}
}
// normalizeStrmTaskPage 钳制队列分页参数(与 repository 一致,保证回显正确)。
func normalizeStrmTaskPage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}
return page, pageSize
}
func timePtrString(t *time.Time) *string {
if t == nil {
return nil
}
v := t.Local().Format(time.RFC3339)
return &v
}
// CancelDownloadTask 取消一个排队/进行中的下载任务。
func (s *StrmService) CancelDownloadTask(ctx context.Context, id string) error {
task, err := s.repo.StrmDownload.FindByID(ctx, id)
if err != nil || task == nil {
return errNotFoundOr(err, "下载任务不存在")
}
if task.Status != model.StrmTaskPending && task.Status != model.StrmTaskRunning {
return errors.New("任务已结束,无法取消")
}
now := time.Now()
task.Status = model.StrmTaskCanceled
task.Error = "已取消"
task.FinishedAt = &now
return s.repo.StrmDownload.Update(ctx, task)
}
// RetryDownloadTask 重试一个失败的下载任务。
func (s *StrmService) RetryDownloadTask(ctx context.Context, id string) error {
task, err := s.repo.StrmDownload.FindByID(ctx, id)
if err != nil || task == nil {
return errNotFoundOr(err, "下载任务不存在")
}
if task.Status != model.StrmTaskFailed && task.Status != model.StrmTaskCanceled {
return errors.New("只有失败/已取消的任务可以重试")
}
task.Status = model.StrmTaskPending
task.Error = ""
task.RetryCount = 0
task.NextTryAt = nil
task.FinishedAt = nil
return s.repo.StrmDownload.Update(ctx, task)
}
// CancelUploadTask 取消一个排队/进行中的上传任务。
func (s *StrmService) CancelUploadTask(ctx context.Context, id string) error {
task, err := s.repo.StrmUpload.FindByID(ctx, id)
if err != nil || task == nil {
return errNotFoundOr(err, "上传任务不存在")
}
if task.Status != model.StrmTaskPending && task.Status != model.StrmTaskRunning {
return errors.New("任务已结束,无法取消")
}
now := time.Now()
task.Status = model.StrmTaskCanceled
task.Error = "已取消"
task.FinishedAt = &now
return s.repo.StrmUpload.Update(ctx, task)
}
// RetryUploadTask 重试一个失败的上传任务。
func (s *StrmService) RetryUploadTask(ctx context.Context, id string) error {
task, err := s.repo.StrmUpload.FindByID(ctx, id)
if err != nil || task == nil {
return errNotFoundOr(err, "上传任务不存在")
}
if task.Status != model.StrmTaskFailed && task.Status != model.StrmTaskCanceled {
return errors.New("只有失败/已取消的任务可以重试")
}
task.Status = model.StrmTaskPending
task.Error = ""
task.RetryCount = 0
task.NextTryAt = nil
task.FinishedAt = nil
return s.repo.StrmUpload.Update(ctx, task)
}
// ─── 下载队列批量操作(handler 使用) ─────────────────────────────────────────
// ClearDoneDownloadTasks 清空全部已完成下载记录,返回删除数量。
func (s *StrmService) ClearDoneDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearDone(ctx)
}
// ClearFinishedDownloadTasks 清空全部已完成与失败的下载记录,返回删除数量。
func (s *StrmService) ClearFinishedDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearFinished(ctx)
}
// RetryAllFailedDownloadTasks 批量重试所有失败下载任务,返回重新入队数量。
func (s *StrmService) RetryAllFailedDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.RetryAllFailed(ctx)
}
// CancelPendingDownloadTasks 批量取消所有排队下载任务,返回取消数量。
func (s *StrmService) CancelPendingDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.CancelPending(ctx)
}
func sleepContext(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}
+670
View File
@@ -0,0 +1,670 @@
// STRM 管理服务:网盘账号、同步目录、STRM 生成与元数据下载/上传队列。
//
// 设计参考 QMediaSync 的 STRM 同步:同步目录扫描网盘(115 / CloudDrive2 /
// OpenList,驱动复用 internal/service/cloud 包)或本地目录,视频文件生成
// 指向本服务播放端点的一行 URL 的 .strm 文件;元数据文件(nfo/图片/字幕)
// 经下载/上传队列与远端双向同步。
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
)
// strm 全局设置键(存于 Setting 表,strm.* 前缀)。
const (
StrmSettingBaseURL = "strm.base_url"
StrmSettingVideoExt = "strm.video_ext"
StrmSettingMetaExt = "strm.meta_ext"
StrmSettingExcludeName = "strm.exclude_name"
StrmSettingMinVideoSizeMB = "strm.min_video_size_mb"
StrmSettingAddPath = "strm.add_path"
StrmSettingDownloadMeta = "strm.download_meta"
StrmSettingUploadMeta = "strm.upload_meta"
StrmSettingDeleteDir = "strm.delete_dir"
StrmSettingDownloadThreads = "strm.download_threads"
StrmSettingUploadThreads = "strm.upload_threads"
)
const (
StrmDefaultVideoExt = "mkv,mp4,avi,rmvb,rm,mov,ts,wmv,flv,m4v,iso,mpg,mpeg,webm"
StrmDefaultMetaExt = "nfo,jpg,jpeg,png,srt,ass,ssa,sub,txt,bmp,webp"
StrmDefaultExclude = "sample,trailer,预告"
)
// StrmSettingDefs 是全局 strm 设置的默认值表,供设置对话框展示。
var StrmSettingDefs = map[string]struct {
Default string
Label string
Kind string // text / number / bool / choice
Choices []string
Help string
}{
StrmSettingBaseURL: {Default: "", Label: "STRM 链接基础地址", Kind: "text", Help: "生成的 strm 文件指向的播放地址(默认留空自动取服务器公网地址 app.server_url)。例如 http://192.168.1.10:8096"},
StrmSettingVideoExt: {Default: StrmDefaultVideoExt, Label: "视频扩展名", Kind: "text", Help: "逗号分隔;命中即生成 .strm,其余文件视为元数据"},
StrmSettingMetaExt: {Default: StrmDefaultMetaExt, Label: "元数据扩展名", Kind: "text", Help: "逗号分隔;进入下载/上传队列的文件类型(nfo/图片/字幕)"},
StrmSettingExcludeName: {Default: StrmDefaultExclude, Label: "排除文件名", Kind: "text", Help: "逗号分隔;文件名包含任一关键词即跳过"},
StrmSettingMinVideoSizeMB: {Default: "0", Label: "最小视频大小(MB)", Kind: "number", Help: "小于该大小的视频文件不生成 STRM,0 表示不限"},
StrmSettingAddPath: {Default: "1", Label: "STRM 链接 path 参数", Kind: "choice", Choices: []string{"1", "2", "3"}, Help: "1=附带完整远端路径 2=仅文件名 3=不带 path"},
StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"},
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端(需网盘支持写入)"},
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
StrmSettingDownloadThreads: {Default: "3", Label: "下载队列线程数", Kind: "number", Help: "元数据下载并发数"},
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"},
}
// StrmAccountSecretKeys 是账号配置中需要加密存储的字段。
var StrmAccountSecretKeys = []string{"cookie", "password", "token", "access_token", "refresh_token"}
// StrmService 提供 STRM 管理的能力。
type StrmService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
crypto *CryptoService
http *http.Client
stopOnce sync.Once
stopCh chan struct{}
baseCtx context.Context // 服务级长期上下文(同步/队列不随 HTTP 请求取消)
mu sync.Mutex
running map[string]context.CancelFunc // sync path id -> cancel
oauthSessions map[string]*strm115AuthSession
}
// NewStrmService constructs the STRM service.
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
return &StrmService{
log: log,
repo: repos,
cfg: cfg,
crypto: crypto,
http: &http.Client{Timeout: 90 * time.Second},
stopCh: make(chan struct{}),
baseCtx: context.Background(),
running: map[string]context.CancelFunc{},
oauthSessions: map[string]*strm115AuthSession{},
}
}
// Start 启动下载/上传队列 worker、定时同步巡检、115 token 刷新与队列清理。
func (s *StrmService) Start(ctx context.Context) {
s.sync115RelayKey(ctx)
downloadThreads := s.strmIntSetting(ctx, StrmSettingDownloadThreads, 3)
if downloadThreads < 1 {
downloadThreads = 1
}
if downloadThreads > 8 {
downloadThreads = 8
}
uploadThreads := s.strmIntSetting(ctx, StrmSettingUploadThreads, 2)
if uploadThreads < 1 {
uploadThreads = 1
}
if uploadThreads > 4 {
uploadThreads = 4
}
for i := 0; i < downloadThreads; i++ {
go s.downloadWorker(ctx)
}
for i := 0; i < uploadThreads; i++ {
go s.uploadWorker(ctx)
}
go s.cronLoop(ctx)
go s.queueCleanupLoop(ctx)
go s.refresh115TokensLoop(ctx)
s.log.Info("strm service started",
zap.Int("download_threads", downloadThreads),
zap.Int("upload_threads", uploadThreads))
}
func (s *StrmService) Stop() {
s.stopOnce.Do(func() { close(s.stopCh) })
}
// ─── 网盘账号 ──────────────────────────────────────────────────────────────────
// strmAccountConfigJSON 序列化账号配置并加密敏感字段。
func (s *StrmService) strmAccountConfigJSON(values map[string]string, encrypt bool) (string, error) {
cfg := make(map[string]string, len(values))
for k, v := range values {
if v == "" {
continue
}
if encrypt && strmContains(StrmAccountSecretKeys, k) {
cfg[k] = s.crypto.Encrypt(v)
} else {
cfg[k] = v
}
}
data, err := json.Marshal(cfg)
if err != nil {
return "", err
}
return string(data), nil
}
// strmAccountConfig 解密账号配置为驱动可直接消费的 map。
func (s *StrmService) strmAccountConfig(acct *model.StrmAccount) (map[string]string, error) {
cfg := map[string]string{}
if acct == nil || strings.TrimSpace(acct.Config) == "" {
return cfg, nil
}
if err := json.Unmarshal([]byte(acct.Config), &cfg); err != nil {
return nil, fmt.Errorf("decode account config: %w", err)
}
for _, k := range StrmAccountSecretKeys {
if v, ok := cfg[k]; ok {
cfg[k] = s.crypto.Decrypt(v)
}
}
return cfg, nil
}
// HasStrmAccountCredential 报告账号是否已配置核心凭据(用于前端展示)。
func HasStrmAccountCredential(acct *model.StrmAccount) bool {
switch acct.Provider {
case model.StrmProvider115:
// 115 开放平台:已授权(含 access_token)才算配置完成
return strings.Contains(acct.Config, `"access_token"`)
case model.StrmProviderOpenList:
return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`)
default:
return strings.Contains(acct.Config, `"password"`) || strings.Contains(acct.Config, `"token"`)
}
}
// CreateStrmAccount 创建网盘账号(校验提供方 + 凭据)。
func (s *StrmService) CreateStrmAccount(ctx context.Context, name, provider string, config map[string]string) (*model.StrmAccount, error) {
provider = strings.TrimSpace(provider)
if provider == "" || provider == model.StrmProviderLocal {
return nil, errors.New("请选择网盘类型")
}
if strings.TrimSpace(name) == "" {
name = providerLabel(provider)
}
enc, err := s.strmAccountConfigJSON(config, true)
if err != nil {
return nil, err
}
acct := &model.StrmAccount{
Name: strings.TrimSpace(name),
Provider: provider,
Config: enc,
Enabled: true,
}
if err := s.repo.StrmAccount.Create(ctx, acct); err != nil {
return nil, err
}
return acct, nil
}
// UpdateStrmAccount 更新账号;config 为空表示保留原凭据。
func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, enabled *bool, config map[string]string) (*model.StrmAccount, error) {
acct, err := s.repo.StrmAccount.FindByID(ctx, id)
if err != nil || acct == nil {
return nil, errNotFoundOr(err, "网盘账号不存在")
}
if strings.TrimSpace(name) != "" {
acct.Name = strings.TrimSpace(name)
}
if enabled != nil {
acct.Enabled = *enabled
}
if len(config) > 0 {
enc, err := s.strmAccountConfigJSON(config, true)
if err != nil {
return nil, err
}
acct.Config = enc
}
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return nil, err
}
return acct, nil
}
// DeleteStrmAccount 删除账号;仍被同步目录引用时拒绝。
func (s *StrmService) DeleteStrmAccount(ctx context.Context, id string) error {
paths, err := s.repo.StrmSyncPath.List(ctx)
if err != nil {
return err
}
for _, p := range paths {
if p.AccountID == id {
return fmt.Errorf("该账号仍被同步目录「%s」引用,请先删除对应同步目录", p.Name)
}
}
if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
return err
}
return nil
}
// TestStrmAccount 连通性测试(Ping),结果写回账号。
func (s *StrmService) TestStrmAccount(ctx context.Context, id string) *model.StrmAccount {
acct, err := s.repo.StrmAccount.FindByID(ctx, id)
if err != nil || acct == nil {
return nil
}
now := time.Now()
acct.LastTestAt = &now
provider, err := s.providerFor(ctx, acct)
if err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
} else if err := provider.Ping(ctx); err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
} else {
acct.LastTestResult = "ok"
acct.LastTestOK = true
}
_ = s.repo.StrmAccount.Update(ctx, acct)
return acct
}
// ListAccounts 返回全部网盘账号。
func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, error) {
return s.repo.StrmAccount.List(ctx)
}
// providerFor 依据账号配置构建网盘驱动。
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
cfg, err := s.strmAccountConfig(acct)
if err != nil {
return nil, err
}
anyCfg := make(map[string]any, len(cfg)+1)
for k, v := range cfg {
anyCfg[k] = v
}
anyCfg["ua"] = defaultStrmUA
provider, err := cloud.New(acct.Provider, anyCfg, s.http)
if err != nil {
return nil, err
}
return provider, nil
}
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
// GetStrmSettings 返回全局 strm 设置(含默认值)。
func (s *StrmService) GetStrmSettings(ctx context.Context) (map[string]string, error) {
out := map[string]string{}
for key, def := range StrmSettingDefs {
value, err := s.repo.Setting.Get(ctx, key)
if err != nil {
return nil, err
}
if strings.TrimSpace(value) == "" {
value = def.Default
}
out[key] = value
}
return out, nil
}
// UpdateStrmSettings 校验并保存全局 strm 设置。
func (s *StrmService) UpdateStrmSettings(ctx context.Context, values map[string]string) error {
for key, value := range values {
def, ok := StrmSettingDefs[key]
if !ok {
continue
}
value = strings.TrimSpace(value)
switch def.Kind {
case "number":
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
return fmt.Errorf("%s 必须是正整数", def.Label)
}
case "bool":
if value != "true" && value != "false" {
return fmt.Errorf("%s 必须是 true/false", def.Label)
}
case "choice":
if !strmContains(def.Choices, value) {
return fmt.Errorf("%s 取值不合法", def.Label)
}
}
if err := s.repo.Setting.Set(ctx, key, value); err != nil {
return err
}
}
s.sync115RelayKey(ctx)
return nil
}
// strmSetting 读取单个 strm 设置(带默认值)。
func (s *StrmService) strmSetting(ctx context.Context, key string) string {
value, err := s.repo.Setting.Get(ctx, key)
if err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
def, ok := StrmSettingDefs[key]
if ok {
return def.Default
}
return ""
}
func (s *StrmService) strmIntSetting(ctx context.Context, key string, fallback int) int {
value := s.strmSetting(ctx, key)
if value == "" {
return fallback
}
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
return fallback
}
return n
}
// ─── 同步目录 ──────────────────────────────────────────────────────────────────
// ListSyncPaths 返回全部同步目录。
func (s *StrmService) ListSyncPaths(ctx context.Context) ([]model.StrmSyncPath, error) {
return s.repo.StrmSyncPath.List(ctx)
}
// ListSyncRecords 返回同步记录。
func (s *StrmService) ListSyncRecords(ctx context.Context, pathID string, limit int) ([]model.StrmSyncRecord, error) {
return s.repo.StrmSyncRecord.List(ctx, pathID, limit)
}
// CreateSyncPath 校验并创建同步目录。
func (s *StrmService) CreateSyncPath(ctx context.Context, p *model.StrmSyncPath) (*model.StrmSyncPath, error) {
if err := s.validateSyncPath(ctx, p); err != nil {
return nil, err
}
if strings.TrimSpace(p.Name) == "" {
p.Name = "同步目录 " + time.Now().Format("01-02 15:04")
}
if p.EnableCron && strings.TrimSpace(p.Cron) == "" {
return nil, errors.New("启用定时同步需要填写 cron 表达式")
}
if err := s.repo.StrmSyncPath.Create(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// UpdateSyncPath 更新同步目录(运行中禁止修改),保留同步状态字段。
func (s *StrmService) UpdateSyncPath(ctx context.Context, id string, p *model.StrmSyncPath) (*model.StrmSyncPath, error) {
existing, err := s.repo.StrmSyncPath.FindByID(ctx, id)
if err != nil || existing == nil {
return nil, errNotFoundOr(err, "同步目录不存在")
}
if s.IsSyncRunning(id) {
return nil, errors.New("该目录正在同步中,请先取消")
}
if err := s.validateSyncPath(ctx, p); err != nil {
return nil, err
}
p.ID = existing.ID
p.CreatedAt = existing.CreatedAt
p.LastSyncAt = existing.LastSyncAt
p.LastSyncStatus = existing.LastSyncStatus
p.LastSyncMessage = existing.LastSyncMessage
if p.EnableCron && strings.TrimSpace(p.Cron) == "" {
return nil, errors.New("启用定时同步需要填写 cron 表达式")
}
if err := s.repo.StrmSyncPath.Update(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// DeleteSyncPath 删除同步目录(运行中禁止删除)。
func (s *StrmService) DeleteSyncPath(ctx context.Context, id string) error {
if s.IsSyncRunning(id) {
return errors.New("该目录正在同步中,请先取消")
}
return s.repo.StrmSyncPath.Delete(ctx, id)
}
// ─── 工具 ──────────────────────────────────────────────────────────────────────
func (s *StrmService) validateSyncPath(ctx context.Context, p *model.StrmSyncPath) error {
p.Provider = strings.TrimSpace(p.Provider)
if p.Provider == "" {
return errors.New("请选择同步类型")
}
if p.Provider == model.StrmProviderLocal {
p.AccountID = ""
if strings.TrimSpace(p.RemotePath) == "" {
return errors.New("本地同步需要填写源目录")
}
} else {
if strings.TrimSpace(p.AccountID) == "" {
return errors.New("请选择网盘账号")
}
acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID)
if err != nil || acct == nil {
return errNotFoundOr(err, "网盘账号不存在")
}
if acct.Provider != p.Provider {
return errors.New("网盘账号类型与同步类型不一致")
}
}
if strings.TrimSpace(p.LocalPath) == "" {
return errors.New("请填写本地输出目录")
}
if err := ensureLocalDir(p.LocalPath); err != nil {
return fmt.Errorf("本地输出目录不可用:%w", err)
}
return nil
}
// strmDefaultBaseURL 兜底默认:所有地址配置都为空时使用本机监听地址。
func strmDefaultBaseURL(cfg *config.Config) string {
port := 8080
if cfg != nil && cfg.App.Port > 0 {
port = cfg.App.Port
}
return fmt.Sprintf("http://127.0.0.1:%d", port)
}
// strmEffectiveConfig 合并全局设置与同步目录覆盖,得出生效配置。
func (s *StrmService) strmEffectiveConfig(ctx context.Context, p *model.StrmSyncPath) (*strmPathConfig, error) {
cfg := &strmPathConfig{
BaseURL: firstNonEmpty(p.StrmBaseURL, s.strmSetting(ctx, StrmSettingBaseURL), PublicServerURL(ctx, s.repo, s.cfg), strmDefaultBaseURL(s.cfg)),
VideoExt: csvSplit(firstNonEmpty(p.VideoExt, s.strmSetting(ctx, StrmSettingVideoExt), StrmDefaultVideoExt)),
MetaExt: csvSplit(firstNonEmpty(p.MetaExt, s.strmSetting(ctx, StrmSettingMetaExt), StrmDefaultMetaExt)),
ExcludeName: csvSplit(firstNonEmpty(p.ExcludeName, s.strmSetting(ctx, StrmSettingExcludeName), StrmDefaultExclude)),
MinSize: p.MinVideoSizeMB * 1 << 20,
AddPath: p.AddPath,
}
if cfg.MinSize <= 0 && p.MinVideoSizeMB <= 0 {
m := s.strmIntSetting(ctx, StrmSettingMinVideoSizeMB, 0)
cfg.MinSize = int64(m) * 1 << 20
}
if cfg.AddPath < 1 || cfg.AddPath > 3 {
if v, err := strconv.Atoi(s.strmSetting(ctx, StrmSettingAddPath)); err == nil && v >= 1 && v <= 3 {
cfg.AddPath = v
} else {
cfg.AddPath = 1
}
}
// 目录级开关是具体值(前端默认从全局默认值带入),不再叠加全局设置
cfg.DownloadMeta = p.DownloadMeta
cfg.UploadMeta = p.UploadMeta
cfg.DeleteDir = p.DeleteDir
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return cfg, nil
}
func csvSplit(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if p := strings.TrimSpace(strings.ToLower(part)); p != "" {
out = append(out, p)
}
}
return out
}
func strmContains(list []string, target string) bool {
for _, item := range list {
if item == target {
return true
}
}
return false
}
func errNotFoundOr(err error, msg string) error {
if err != nil {
return err
}
return errors.New(msg)
}
// providerLabel 提供方中文名(前端同名映射)。
func providerLabel(provider string) string {
switch provider {
case model.StrmProvider115:
return "115 网盘"
case model.StrmProviderCloudDrive:
return "CloudDrive2"
case model.StrmProviderOpenList:
return "OpenList"
case model.StrmProviderLocal:
return "本地目录"
default:
return provider
}
}
// StrmProviderLabels 提供给方的展示标签。
var StrmProviderLabels = map[string]string{
model.StrmProvider115: "115 网盘",
model.StrmProviderCloudDrive: "CloudDrive2",
model.StrmProviderOpenList: "OpenList",
model.StrmProviderLocal: "本地目录",
}
const defaultStrmUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-Strm/1.0"
// ensureLocalDir 创建本地输出目录。
func ensureLocalDir(dir string) error {
return os.MkdirAll(dir, 0o755)
}
// joinLocalRel 拼接本地目标路径并校验不越出根目录。
func joinLocalRel(root, rel string) (string, error) {
root = filepath.Clean(root)
target := filepath.Clean(filepath.Join(root, filepath.FromSlash(rel)))
if target != root && !strings.HasPrefix(target, root+string(filepath.Separator)) {
return "", errors.New("本地路径越界")
}
return target, nil
}
// strmPathConfig 是同步目录的生效配置快照。
type strmPathConfig struct {
BaseURL string
VideoExt []string
MetaExt []string
ExcludeName []string
MinSize int64
AddPath int
DownloadMeta bool
UploadMeta bool
DeleteDir bool
}
// ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ─────────────────────────
// StrmLocalDirEntry 是本地目录选择器的一个条目。
type StrmLocalDirEntry struct {
Name string `json:"name"`
Path string `json:"path"`
}
// StrmLocalDirList 是本地目录浏览结果。
type StrmLocalDirList struct {
Roots bool `json:"roots"` // true=正在显示根/盘符列表
Parent string `json:"parent,omitempty"` // 上级目录(为空表示没有)
Current string `json:"current,omitempty"` // 当前目录
Children []StrmLocalDirEntry `json:"children"`
}
// ListStrmLocalDirs 列出本地目录的子目录;path 为空时返回根/盘符列表。
func (s *StrmService) ListStrmLocalDirs(ctx context.Context, path string) (*StrmLocalDirList, error) {
path = strings.TrimSpace(path)
if path == "" {
if isWindows() {
// 列出存在的盘符
children := make([]StrmLocalDirEntry, 0, 4)
for _, letter := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
vol := string(letter) + ":\\"
if _, err := os.Stat(vol); err == nil {
children = append(children, StrmLocalDirEntry{Name: vol, Path: vol})
}
}
if len(children) == 0 {
children = append(children, StrmLocalDirEntry{Name: "C:\\", Path: "C:\\"})
}
return &StrmLocalDirList{Roots: true, Children: children}, nil
}
return &StrmLocalDirList{Roots: true, Children: []StrmLocalDirEntry{{Name: "/", Path: "/"}}}, nil
}
clean := filepath.Clean(path)
info, err := os.Stat(clean)
if err != nil {
return nil, fmt.Errorf("目录不可访问:%w", err)
}
if !info.IsDir() {
return nil, errors.New("所选路径不是目录")
}
entries, err := os.ReadDir(clean)
if err != nil {
return nil, fmt.Errorf("读取目录失败:%w", err)
}
children := make([]StrmLocalDirEntry, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if strings.HasPrefix(entry.Name(), ".") && entry.Name() != "." && entry.Name() != ".." {
continue // 隐藏目录不显示(避免噪音)
}
children = append(children, StrmLocalDirEntry{
Name: entry.Name(),
Path: filepath.Join(clean, entry.Name()),
})
}
sort.Slice(children, func(i, j int) bool { return children[i].Name < children[j].Name })
parent := filepath.Dir(clean)
if parent == clean {
parent = "" // 已到根/盘符根
}
return &StrmLocalDirList{Parent: parent, Current: clean, Children: children}, nil
}
func isWindows() bool {
return runtime.GOOS == "windows"
}
+798
View File
@@ -0,0 +1,798 @@
// STRM 同步引擎:扫描网盘/本地目录,生成 .strm 文件,按需入队元数据下载/上传,
// 并清理远端已不存在的本地多余文件。参考 QMediaSync 的 STRM 同步流程实现。
package service
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
)
// strmSyncState 是一次同步执行的上下文。
type strmSyncState struct {
s *StrmService
ctx context.Context
p *model.StrmSyncPath
acct *model.StrmAccount
provider cloud.Provider // local 提供方为 nil
cfg *strmPathConfig
rec *model.StrmSyncRecord
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string]int64 // 远端元数据大小(上传比对用)
}
// StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。
func (s *StrmService) StartSync(ctx context.Context, pathID string) error {
p, err := s.repo.StrmSyncPath.FindByID(ctx, pathID)
if err != nil || p == nil {
return errNotFoundOr(err, "同步目录不存在")
}
if !p.Enabled {
return errors.New("同步目录已禁用")
}
if p.Provider == model.StrmProviderLocal && strings.TrimSpace(p.RemotePath) == "" {
return errors.New("本地同步需要填写源目录")
}
if p.Provider != model.StrmProviderLocal && strings.TrimSpace(p.AccountID) == "" {
return errors.New("该同步目录未关联网盘账号")
}
s.mu.Lock()
if _, exists := s.running[pathID]; exists {
s.mu.Unlock()
return errors.New("该目录正在同步中")
}
// 同步在后台持续执行,不受 HTTP 请求生命周期影响
runCtx, cancel := context.WithCancel(s.baseCtx)
s.running[pathID] = cancel
s.mu.Unlock()
now := time.Now()
rec := &model.StrmSyncRecord{
SyncPathID: pathID,
Status: model.StrmSyncRecordRunning,
StartedAt: &now,
}
if err := s.repo.StrmSyncRecord.Create(ctx, rec); err != nil {
s.clearRunning(pathID)
return err
}
status := model.StrmSyncRecordRunning
p.LastSyncAt = &now
p.LastSyncStatus = status
p.LastSyncMessage = "同步进行中"
_ = s.repo.StrmSyncPath.Update(ctx, p)
go s.runSync(runCtx, p, rec)
return nil
}
// CancelSync 取消正在进行的同步。
func (s *StrmService) CancelSync(ctx context.Context, pathID string) error {
s.mu.Lock()
defer s.mu.Unlock()
cancel, exists := s.running[pathID]
if !exists {
return errors.New("该目录当前没有进行中的同步")
}
cancel()
return nil
}
// IsSyncRunning 报告某同步目录是否正在同步。
func (s *StrmService) IsSyncRunning(pathID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, exists := s.running[pathID]
return exists
}
func (s *StrmService) clearRunning(pathID string) {
s.mu.Lock()
delete(s.running, pathID)
s.mu.Unlock()
}
// ListRemoteDir 列出网盘账号某目录下的条目(供前端目录选择器使用)。
func (s *StrmService) ListRemoteDir(ctx context.Context, accountID, dir string) ([]cloud.FileEntry, error) {
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil || acct == nil {
return nil, errNotFoundOr(err, "网盘账号不存在")
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
return nil, err
}
if dir == "" {
if acct.Provider == model.StrmProvider115 {
dir = "0"
} else {
dir = "/"
}
}
return provider.List(ctx, dir)
}
// runSync 执行同步主体;结束时更新记录与目录状态。
func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *model.StrmSyncRecord) {
defer s.clearRunning(p.ID)
cfg, err := s.strmEffectiveConfig(ctx, p)
if err != nil {
s.finishSync(p, rec, model.StrmSyncRecordFailed, err.Error())
return
}
st := &strmSyncState{
s: s,
ctx: ctx,
p: p,
cfg: cfg,
rec: rec,
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
}
if p.Provider != model.StrmProviderLocal {
acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID)
if err != nil || acct == nil {
s.finishSync(p, rec, model.StrmSyncRecordFailed, "网盘账号不存在或已删除")
return
}
if !acct.Enabled {
s.finishSync(p, rec, model.StrmSyncRecordFailed, "网盘账号已禁用")
return
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
s.finishSync(p, rec, model.StrmSyncRecordFailed, err.Error())
return
}
st.acct = acct
st.provider = provider
}
if err := st.run(); err != nil {
if errors.Is(err, context.Canceled) {
s.finishSync(p, rec, model.StrmSyncRecordCanceled, "已取消")
} else {
s.finishSync(p, rec, model.StrmSyncRecordFailed, err.Error())
}
return
}
s.finishSync(p, rec, model.StrmSyncRecordDone, "")
}
// finishSync 落库同步结果。
func (s *StrmService) finishSync(p *model.StrmSyncPath, rec *model.StrmSyncRecord, status, message string) {
now := time.Now()
rec.Status = status
rec.Message = message
rec.FinishedAt = &now
if err := s.repo.StrmSyncRecord.Update(context.Background(), rec); err != nil {
s.log.Warn("update strm sync record failed", zap.Error(err))
}
p.LastSyncStatus = status
p.LastSyncMessage = message
if status != model.StrmSyncRecordFailed && message == "" {
p.LastSyncMessage = fmt.Sprintf("完成:新增/更新 %d 个 strm,下载 %d 个元数据,清理 %d 个文件",
rec.NewStrm, rec.NewMeta, rec.Pruned)
}
if err := s.repo.StrmSyncPath.Update(context.Background(), p); err != nil {
s.log.Warn("update strm sync path failed", zap.Error(err))
}
s.log.Info("strm sync finished",
zap.String("path_id", p.ID), zap.String("status", status),
zap.Int64("new_strm", rec.NewStrm), zap.Int64("new_meta", rec.NewMeta),
zap.Int64("pruned", rec.Pruned), zap.String("message", message))
}
func (st *strmSyncState) run() error {
if err := ensureLocalDir(st.p.LocalPath); err != nil {
return fmt.Errorf("创建输出目录失败:%w", err)
}
if st.provider != nil {
if err := st.walkRemote(); err != nil {
return err
}
} else {
if err := st.walkLocalSource(); err != nil {
return err
}
}
st.flushProgress()
if st.cfg.UploadMeta && st.provider != nil && st.p.Provider != model.StrmProvider115 {
if err := st.scanLocalMetaForUpload(); err != nil {
return err
}
}
if err := st.pruneLocal(); err != nil {
return err
}
st.flushProgress()
_ = st.ctx.Err()
return nil
}
// walkRemote 广度优先遍历网盘目录树。
func (st *strmSyncState) walkRemote() error {
root := strings.TrimSpace(st.p.RemotePath)
if root == "" {
root = "/"
}
type dirTask struct {
id string
rel string
}
queue := []dirTask{{id: root, rel: ""}}
for len(queue) > 0 {
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
task := queue[0]
queue = queue[1:]
entries, err := st.provider.List(st.ctx, task.id)
if err != nil {
return fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
}
for _, entry := range entries {
rel := entry.Name
if task.rel != "" {
rel = task.rel + "/" + entry.Name
}
if entry.IsDir {
queue = append(queue, dirTask{id: entry.ID, rel: rel})
continue
}
st.processRemoteFile(entry, rel)
}
}
return nil
}
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
func (st *strmSyncState) processRemoteFile(entry cloud.FileEntry, rel string) {
fileName := entry.Name
if st.isExcluded(fileName) {
return
}
ext := strings.ToLower(filepath.Ext(fileName))
switch {
case st.isVideoExt(ext, entry.Size):
st.handleVideo(entry, rel, ext)
case st.cfg.DownloadMeta && st.isMetaExt(ext):
st.handleMeta(entry, rel, ext)
default:
st.touchProgress()
}
}
func (st *strmSyncState) isExcluded(fileName string) bool {
lower := strings.ToLower(fileName)
for _, keyword := range st.cfg.ExcludeName {
if keyword != "" && strings.Contains(lower, keyword) {
return true
}
}
return false
}
func (st *strmSyncState) isVideoExt(ext string, size int64) bool {
if st.cfg.MinSize > 0 && size < st.cfg.MinSize {
return false
}
for _, e := range st.cfg.VideoExt {
if "."+e == ext {
return true
}
}
return false
}
func (st *strmSyncState) isMetaExt(ext string) bool {
for _, e := range st.cfg.MetaExt {
if "."+e == ext {
return true
}
}
return false
}
// handleVideo 生成/更新 .strm 文件。
func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
relSansExt := rel[:len(rel)-len(ext)]
st.mu.Lock()
if st.seenVideo["v:"+relSansExt] {
st.mu.Unlock()
return
}
st.seenVideo["v:"+relSansExt] = true
st.mu.Unlock()
targetRel := relSansExt + ".strm"
target, err := joinLocalRel(st.p.LocalPath, targetRel)
if err != nil {
st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err))
return
}
content, err := st.strmContent(entry, rel, ext)
if err != nil {
st.rec.Message = err.Error()
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
return
}
existing := ""
if data, err := os.ReadFile(target); err == nil {
existing = string(data)
}
if existing == content {
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
st.touchProgress()
return
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
st.s.log.Warn("mkdir strm dir failed", zap.String("dir", filepath.Dir(target)), zap.Error(err))
return
}
tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
st.s.log.Warn("write strm tmp failed", zap.String("file", target), zap.Error(err))
return
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err))
return
}
st.mu.Lock()
st.rec.NewStrm++
st.mu.Unlock()
st.touchProgress()
}
// strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。
func (st *strmSyncState) strmContent(entry cloud.FileEntry, rel, ext string) (string, error) {
q := url.Values{}
switch st.p.Provider {
case model.StrmProvider115:
q.Set("acct", st.p.AccountID)
q.Set("pickcode", entry.PickCode)
case model.StrmProviderCloudDrive, model.StrmProviderOpenList:
q.Set("acct", st.p.AccountID)
q.Set("ref", entry.ID)
case model.StrmProviderLocal:
src, err := joinLocalRel(st.p.RemotePath, rel)
if err != nil {
return "", err
}
q.Set("path", src)
default:
return "", fmt.Errorf("不支持的提供方:%s", st.p.Provider)
}
// 本地源用 path 参数携带真实文件路径;网盘源用 path 参数展示目录结构
if st.p.Provider != model.StrmProviderLocal {
if pathParam := st.strmPathParam(rel); pathParam != "" {
q.Set("path", pathParam)
}
}
suffix := ""
if encoded := q.Encode(); encoded != "" {
suffix = "?" + encoded
}
return st.cfg.BaseURL + "/api/strm/play/" + st.p.Provider + "/video" + ext + suffix, nil
}
// strmPathParam 按 add_path 模式生成 path 查询参数(1=完整相对路径 2=仅文件名 3=不带)。
func (st *strmSyncState) strmPathParam(rel string) string {
switch st.cfg.AddPath {
case 1:
return rel
case 2:
_, name := filepath.Split(rel)
return name
default:
return ""
}
}
// handleMeta 元数据入下载队列(本地已存在且大小一致则跳过)。
func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) {
st.mu.Lock()
st.seenMeta["m:"+rel] = true
st.remoteMeta["m:"+rel] = entry.Size
st.mu.Unlock()
target, err := joinLocalRel(st.p.LocalPath, rel)
if err != nil {
return
}
if info, err := os.Stat(target); err == nil && info.Size() == entry.Size {
st.touchProgress()
return
}
if st.taskExists("download", st.p.ID, target) {
st.touchProgress()
return
}
task := &model.StrmDownloadTask{
SyncPathID: st.p.ID,
AccountID: st.p.AccountID,
Provider: st.p.Provider,
FileName: entry.Name,
RemoteRef: entry.PickCode,
RemoteDir: st.p.RemotePath,
LocalPath: target,
Size: entry.Size,
Status: model.StrmTaskPending,
}
if task.RemoteRef == "" {
task.RemoteRef = entry.ID
}
// 115 用 pickcode 定位;DAV/OpenList 用路径定位
if st.p.Provider != model.StrmProvider115 {
task.RemoteRef = entry.ID
}
if err := st.s.repo.StrmDownload.Create(st.ctx, task); err != nil {
st.s.log.Warn("enqueue strm download task failed", zap.Error(err))
return
}
st.mu.Lock()
st.rec.NewMeta++
st.mu.Unlock()
st.touchProgress()
}
// walkLocalSource 本地源:视频生成 STRM,元数据就地存在。
func (st *strmSyncState) walkLocalSource() error {
srcRoot := filepath.Clean(st.p.RemotePath)
info, err := os.Stat(srcRoot)
if err != nil {
return fmt.Errorf("本地源目录不可访问:%w", err)
}
if !info.IsDir() {
return errors.New("本地源目录不是目录")
}
return filepath.WalkDir(srcRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if path == srcRoot {
return nil
}
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(srcRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
if st.isExcluded(rel) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
ext := strings.ToLower(filepath.Ext(rel))
if !st.isVideoExt(ext, info.Size()) {
st.touchProgress()
return nil
}
relSansExt := rel[:len(rel)-len(ext)]
st.mu.Lock()
st.seenVideo["v:"+relSansExt] = true
st.mu.Unlock()
content, err := st.strmContent(cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size()}, rel, ext)
if err != nil {
return nil
}
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
if err != nil {
return nil
}
if data, err := os.ReadFile(target); err == nil && string(data) == content {
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
return nil
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil
}
tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
_ = os.Rename(tmp, target)
} else {
_ = os.Remove(tmp)
}
st.mu.Lock()
st.rec.NewStrm++
st.mu.Unlock()
return nil
})
}
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
func (st *strmSyncState) scanLocalMetaForUpload() error {
localRoot := filepath.Clean(st.p.LocalPath)
return filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if path == localRoot {
return nil
}
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(localRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
if !st.isMetaExt(ext) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
st.mu.Lock()
remoteSize, exists := st.remoteMeta["m:"+rel]
st.mu.Unlock()
if exists && remoteSize == info.Size() {
return nil
}
if st.taskExists("upload", st.p.ID, rel) {
return nil
}
task := &model.StrmUploadTask{
SyncPathID: st.p.ID,
AccountID: st.p.AccountID,
Provider: st.p.Provider,
FileName: filepath.Base(rel),
LocalPath: path,
RemotePath: st.remoteUploadPath(rel),
Size: info.Size(),
Status: model.StrmTaskPending,
}
if err := st.s.repo.StrmUpload.Create(st.ctx, task); err != nil {
st.s.log.Warn("enqueue strm upload task failed", zap.Error(err))
return nil
}
st.mu.Lock()
st.rec.Uploaded++
st.mu.Unlock()
return nil
})
}
// remoteUploadPath 远端元数据目标路径 = 同步目录远端根 + 相对路径。
func (st *strmSyncState) remoteUploadPath(rel string) string {
root := strings.TrimRight(normalizeRemotePath(st.p.RemotePath), "/")
if root == "/" || root == "" {
return "/" + rel
}
return root + "/" + rel
}
// taskExists 检查是否已有同目录、同目标的进行中/已完成任务(避免重复入队)。
func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
ctx := st.ctx
var count int64
switch kind {
case "download":
count = st.s.repo.StrmDownload.CountActive(ctx, syncPathID, localPath)
default:
count = st.s.repo.StrmUpload.CountActive(ctx, syncPathID, localPath)
}
return count > 0
}
// pruneLocal 清理本地多余 .strm 与元数据(远端已不存在),可选删除空目录。
func (st *strmSyncState) pruneLocal() error {
localRoot := filepath.Clean(st.p.LocalPath)
var dirs []string
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if path == localRoot {
return nil
}
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
if d.IsDir() {
dirs = append(dirs, path)
return nil
}
rel, err := filepath.Rel(localRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
remove := false
switch {
case ext == ".strm":
relSansExt := rel[:len(rel)-len(ext)]
st.mu.Lock()
remove = !st.seenVideo["v:"+relSansExt]
st.mu.Unlock()
case st.isMetaExt(ext) && st.cfg.DownloadMeta && !st.cfg.UploadMeta && st.p.Provider != model.StrmProviderLocal:
st.mu.Lock()
remove = !st.seenMeta["m:"+rel]
st.mu.Unlock()
}
if remove {
if err := os.Remove(path); err == nil {
st.mu.Lock()
st.rec.Pruned++
st.mu.Unlock()
}
}
return nil
})
if err != nil {
return err
}
if st.cfg.DeleteDir {
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err == nil && len(entries) == 0 {
_ = os.Remove(dir)
}
}
}
return nil
}
// touchProgress 每处理若干个文件落库一次进度。
func (st *strmSyncState) touchProgress() {
st.mu.Lock()
st.rec.Total++
st.processed++
flush := st.processed%100 == 0
st.mu.Unlock()
if flush {
st.flushProgress()
}
}
func (st *strmSyncState) flushProgress() {
st.mu.Lock()
rec := *st.rec
st.mu.Unlock()
if err := st.s.repo.StrmSyncRecord.Update(st.ctx, &rec); err != nil {
st.s.log.Warn("update strm sync progress failed", zap.Error(err))
}
}
// ─── 定时同步巡检 ──────────────────────────────────────────────────────────────
func (s *StrmService) cronLoop(ctx context.Context) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case now := <-ticker.C:
paths, err := s.repo.StrmSyncPath.List(ctx)
if err != nil {
continue
}
for i := range paths {
p := &paths[i]
if !p.Enabled || !p.EnableCron || strings.TrimSpace(p.Cron) == "" {
continue
}
if !cronMatches(p.Cron, now) {
continue
}
s.mu.Lock()
_, running := s.running[p.ID]
s.mu.Unlock()
if running {
continue
}
s.log.Info("strm cron triggered sync", zap.String("path_id", p.ID), zap.String("cron", p.Cron))
if err := s.StartSync(ctx, p.ID); err != nil {
s.log.Warn("strm cron sync start failed", zap.String("path_id", p.ID), zap.Error(err))
}
}
}
}
}
// cronMatches 匹配 5 段 cron 表达式(分 时 日 月 周)。支持 * ? /n a-b a,b 组合。
func cronMatches(expr string, t time.Time) bool {
fields := strings.Fields(strings.TrimSpace(expr))
if len(fields) != 5 {
return false
}
now := []int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())}
if now[4] == 0 {
now[4] = 7 // 周日常量统一为 7
}
for i, field := range fields {
if !cronFieldMatches(field, now[i]) {
return false
}
}
return true
}
// cronFieldMatches 匹配单个 cron 字段。
func cronFieldMatches(field string, value int) bool {
if field == "*" || field == "?" {
return true
}
for _, item := range strings.Split(field, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if strings.HasPrefix(item, "*/") {
step, err := strconv.Atoi(strings.TrimPrefix(item, "*/"))
if err != nil || step <= 0 {
continue
}
if value%step == 0 {
return true
}
continue
}
if strings.Contains(item, "-") {
bounds := strings.SplitN(item, "-", 2)
lo, errLo := strconv.Atoi(strings.TrimSpace(bounds[0]))
hi, errHi := strconv.Atoi(strings.TrimSpace(bounds[1]))
if errLo == nil && errHi == nil && lo <= hi && value >= lo && value <= hi {
return true
}
continue
}
n, err := strconv.Atoi(item)
if err == nil && n == value {
return true
}
}
return false
}
+204
View File
@@ -0,0 +1,204 @@
package service
import (
"context"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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"
)
// testStrmService 构建带内存库的 StrmService。
// 同步引擎在独立 goroutine 里访问 DB,因此必须用共享缓存的内存库,
// 否则每个连接都会得到一张空表。
func testStrmService(t *testing.T) *StrmService {
t.Helper()
dsn := "file:strmtest_" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
if sqlDB, err := db.DB(); err == nil {
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)
ctx := context.Background()
if err := repos.Setting.Set(ctx, StrmSettingBaseURL, "http://test.local:8096"); err != nil {
t.Fatal(err)
}
return NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
}
func syncPathRecord(t *testing.T, svc *StrmService, provider, remote, local string, enabled bool) *model.StrmSyncPath {
t.Helper()
p, err := svc.CreateSyncPath(context.Background(), &model.StrmSyncPath{
Name: "test",
Provider: provider,
RemotePath: remote,
LocalPath: local,
AddPath: 1,
Enabled: enabled,
DownloadMeta: true,
})
if err != nil {
t.Fatalf("create sync path: %v", err)
}
return p
}
func writeFile(t *testing.T, path string, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
func waitSyncDone(t *testing.T, svc *StrmService, pathID string, timeout time.Duration) *model.StrmSyncRecord {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
records, err := svc.repo.StrmSyncRecord.List(context.Background(), pathID, 5)
if err != nil {
t.Fatal(err)
}
if len(records) > 0 && records[0].Status != model.StrmSyncRecordRunning &&
records[0].Status != model.StrmSyncRecordPending {
return &records[0]
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("sync did not finish in time")
return nil
}
// TestLocalStrmSync 本地目录:视频 → .strm 生成、内容指向播放端点、
// 二次同步跳过、远端删除后清理。
func TestLocalStrmSync(t *testing.T) {
svc := testStrmService(t)
src := t.TempDir()
out := t.TempDir()
writeFile(t, filepath.Join(src, "电影", "阿凡达.mkv"), "fake-video-data")
writeFile(t, filepath.Join(src, "电影", "阿凡达.nfo"), "<xml/>")
p := syncPathRecord(t, svc, model.StrmProviderLocal, src, out, true)
if err := svc.StartSync(context.Background(), p.ID); err != nil {
t.Fatal(err)
}
record := waitSyncDone(t, svc, p.ID, 10*time.Second)
if record.Status != model.StrmSyncRecordDone {
t.Fatalf("sync status = %s, message = %s", record.Status, record.Message)
}
if record.NewStrm != 1 {
t.Fatalf("expected 1 new strm, got %d", record.NewStrm)
}
strmPath := filepath.Join(out, "电影", "阿凡达.strm")
strmContent, err := os.ReadFile(strmPath)
if err != nil {
t.Fatalf("strm file not created: %v", err)
}
content := string(strmContent)
if !strings.Contains(content, "/api/strm/play/local/video.mkv?") || !strings.Contains(content, "path=") {
t.Fatalf("unexpected strm content: %s", content)
}
playURL, err := url.Parse(content)
if err != nil {
t.Fatalf("parse strm url: %v", err)
}
gotPath, err := url.QueryUnescape(playURL.Query().Get("path"))
if err != nil {
t.Fatalf("unescape path: %v", err)
}
wantPath := filepath.Join(src, "电影", "阿凡达.mkv")
if gotPath != wantPath {
t.Fatalf("strm path param = %q, want %q (content: %s)", gotPath, wantPath, content)
}
// 二次同步:内容未变应跳过
if err := svc.StartSync(context.Background(), p.ID); err != nil {
t.Fatal(err)
}
record = waitSyncDone(t, svc, p.ID, 10*time.Second)
if record.Skipped != 1 {
t.Fatalf("expected 1 skipped on second sync, got %d", record.Skipped)
}
// 删除远端视频后同步应清理本地 strm
if err := os.Remove(filepath.Join(src, "电影", "阿凡达.mkv")); err != nil {
t.Fatal(err)
}
if err := svc.StartSync(context.Background(), p.ID); err != nil {
t.Fatal(err)
}
record = waitSyncDone(t, svc, p.ID, 10*time.Second)
if record.Pruned != 1 {
t.Fatalf("expected 1 pruned, got %d", record.Pruned)
}
if _, err := os.Stat(strmPath); !os.IsNotExist(err) {
t.Fatalf("strm file should have been pruned, got err=%v", err)
}
}
// TestStrmCronMatches cron 表达式匹配。
func TestStrmCronMatches(t *testing.T) {
cases := []struct {
expr string
now time.Time
want bool
}{
{"* * * * *", time.Date(2026, 8, 24, 10, 30, 0, 0, time.Local), true},
{"30 10 * * *", time.Date(2026, 8, 24, 10, 30, 0, 0, time.Local), true},
{"30 10 * * *", time.Date(2026, 8, 24, 10, 31, 0, 0, time.Local), false},
{"*/15 * * * *", time.Date(2026, 8, 24, 10, 30, 0, 0, time.Local), true},
{"*/15 * * * *", time.Date(2026, 8, 24, 10, 34, 0, 0, time.Local), false},
{"0 */6 * * *", time.Date(2026, 8, 24, 18, 0, 0, 0, time.Local), true},
{"0 */6 * * *", time.Date(2026, 8, 24, 19, 0, 0, 0, time.Local), false},
{"0 2 * * 1-5", time.Date(2026, 8, 24, 2, 0, 0, 0, time.Local), true}, // 周一
{"0 2 * * 1-5", time.Date(2026, 8, 23, 2, 0, 0, 0, time.Local), false}, // 周日
{"bad", time.Now(), false},
}
for _, tc := range cases {
if got := cronMatches(tc.expr, tc.now); got != tc.want {
t.Errorf("cronMatches(%q) = %v, want %v", tc.expr, got, tc.want)
}
}
}
// TestStrmDefaultBaseURL 无任何地址配置时自动使用本机监听地址。
func TestStrmDefaultBaseURL(t *testing.T) {
if got := strmDefaultBaseURL(nil); got != "http://127.0.0.1:8080" {
t.Fatalf("default base url = %q", got)
}
if got := strmDefaultBaseURL(&config.Config{App: config.AppConfig{Port: 9000}}); got != "http://127.0.0.1:9000" {
t.Fatalf("default base url with port = %q", got)
}
// strmEffectiveConfig 无配置不报错且默认兜底
svc := testStrmService(t)
p := &model.StrmSyncPath{Provider: model.StrmProviderLocal, AddPath: 1, DownloadMeta: true}
cfg, err := svc.strmEffectiveConfig(context.Background(), p)
if err != nil {
t.Fatalf("effective config should not fail without base url: %v", err)
}
if cfg.BaseURL == "" {
t.Fatal("effective config base url should fall back to default")
}
}
+325 -1
View File
@@ -14,6 +14,7 @@
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"lucide-react": "^0.379.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
@@ -22,6 +23,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -1437,6 +1439,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1444,6 +1456,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
@@ -1790,6 +1812,30 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1982,6 +2028,15 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2051,6 +2106,17 @@
"node": ">= 6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2060,6 +2126,24 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2150,6 +2234,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2173,6 +2266,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2201,6 +2300,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -2726,6 +2831,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2953,6 +3067,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3353,11 +3476,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3420,6 +3551,15 @@
"node": ">= 6"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
@@ -3612,6 +3752,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3740,6 +3897,21 @@
"node": ">=8.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -3861,6 +4033,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3900,6 +4078,32 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -4135,6 +4339,13 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -4268,6 +4479,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4278,6 +4495,26 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -4285,6 +4522,93 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -16,6 +16,7 @@
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"lucide-react": "^0.379.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
@@ -24,6 +25,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
+163
View File
@@ -0,0 +1,163 @@
import { api } from './client'
import type {
StrmAccount,
StrmAccountInput,
StrmQueueSnapshot,
StrmSettingsMap,
StrmSyncPath,
StrmSyncPathInput,
StrmSyncRecord,
} from '../types/strm'
export interface StrmRemoteEntry {
id: string
name: string
is_dir: boolean
size: number
pick_code?: string
}
export interface Strm115Source {
auth_source_type: 'built_in_appid' | 'built_in_relay' | 'third_party_service' | 'custom_appid'
auth_provider: string
app_id: string
app_name: string
display_name: string
auth_server?: string
requires_encryption_key?: boolean
deprecated?: boolean
}
export interface StrmLocalDirList {
roots: boolean
parent?: string
current?: string
children: { name: string; path: string }[]
}
export interface Strm115Sources {
built_in: Strm115Source[]
relay: Strm115Source[]
third_party: Strm115Source[]
}
export interface Strm115OAuthStartResult {
session_id: string
mode: 'qrcode' | 'url'
auth_url?: string
state?: string
expires_in?: number
qrcode?: {
uid: string
time: number
sign: string
qrcode: string
}
}
export interface Strm115OAuthStatus {
status: 'waiting' | 'scanned' | 'confirmed' | 'expired'
tip: string
}
export const strmAPI = {
// ── 网盘账号 ────────────────────────────────────────────
listAccounts: () => api.get<StrmAccount[]>('/admin/strm/accounts').then((r) => r.data),
createAccount: (payload: StrmAccountInput) =>
api.post<StrmAccount>('/admin/strm/accounts', payload).then((r) => r.data),
updateAccount: (id: string, payload: StrmAccountInput) =>
api.put<StrmAccount>(`/admin/strm/accounts/${id}`, payload).then((r) => r.data),
deleteAccount: (id: string) => api.delete(`/admin/strm/accounts/${id}`).then((r) => r.data),
testAccount: (id: string) =>
api.post<StrmAccount>(`/admin/strm/accounts/${id}/test`).then((r) => r.data),
listRemoteDir: (accountId: string, dir: string) =>
api
.get<StrmRemoteEntry[]>(`/admin/strm/accounts/${accountId}/list`, { params: { dir } })
.then((r) => r.data),
list115Sources: () =>
api.get<Strm115Sources>('/admin/strm/115/sources').then((r) => r.data),
start115OAuth: (accountId: string, payload: { auth_source: string; app_id?: string; provider?: string; redirect_url?: string }) =>
api.post<Strm115OAuthStartResult>(`/admin/strm/accounts/${accountId}/oauth/start`, payload).then((r) => r.data),
poll115OAuth: (accountId: string, sessionId: string) =>
api.post<Strm115OAuthStatus>(`/admin/strm/accounts/${accountId}/oauth/poll`, { session_id: sessionId }).then((r) => r.data),
// ── 全局设置 ────────────────────────────────────────────
getSettings: () =>
api.get<StrmSettingsMap>('/admin/strm/settings').then((r) => r.data),
updateSettings: (payload: Partial<StrmSettingsMap>) =>
api.put('/admin/strm/settings', payload).then((r) => r.data),
// ── 同步目录 ────────────────────────────────────────────
listPaths: () => api.get<StrmSyncPath[]>('/admin/strm/paths').then((r) => r.data),
createPath: (payload: StrmSyncPathInput) =>
api.post<StrmSyncPath>('/admin/strm/paths', payload).then((r) => r.data),
updatePath: (id: string, payload: StrmSyncPathInput) =>
api.put<StrmSyncPath>(`/admin/strm/paths/${id}`, payload).then((r) => r.data),
deletePath: (id: string) => api.delete(`/admin/strm/paths/${id}`).then((r) => r.data),
startSync: (id: string) => api.post(`/admin/strm/paths/${id}/sync`).then((r) => r.data),
cancelSync: (id: string) => api.post(`/admin/strm/paths/${id}/cancel`).then((r) => r.data),
listRecords: (pathId?: string) =>
api
.get<StrmSyncRecord[]>('/admin/strm/records', { params: pathId ? { path_id: pathId } : {} })
.then((r) => r.data),
// ── 本地目录浏览(同步目录选择器) ────────────────────────
listLocalDirs: (path?: string) =>
api
.get<StrmLocalDirList>('/admin/strm/local-dirs', { params: path ? { path } : {} })
.then((r) => r.data),
// ── 下载/上传队列 ───────────────────────────────────────
downloads: (status?: string, page = 1, pageSize = 50) =>
api
.get<StrmQueueSnapshot>('/admin/strm/downloads', {
params: { status, page, page_size: pageSize },
})
.then((r) => r.data),
cancelDownload: (id: string) =>
api.post(`/admin/strm/downloads/${id}/cancel`).then((r) => r.data),
retryDownload: (id: string) =>
api.post(`/admin/strm/downloads/${id}/retry`).then((r) => r.data),
clearDoneDownloads: () =>
api.post<{ deleted: number }>('/admin/strm/downloads/clear-done').then((r) => r.data),
clearFinishedDownloads: () =>
api.post<{ deleted: number }>('/admin/strm/downloads/clear-finished').then((r) => r.data),
retryFailedDownloads: () =>
api.post<{ retried: number }>('/admin/strm/downloads/retry-failed').then((r) => r.data),
cancelPendingDownloads: () =>
api.post<{ canceled: number }>('/admin/strm/downloads/cancel-pending').then((r) => r.data),
uploads: (status?: string, page = 1, pageSize = 50) =>
api
.get<StrmQueueSnapshot>('/admin/strm/uploads', {
params: { status, page, page_size: pageSize },
})
.then((r) => r.data),
cancelUpload: (id: string) =>
api.post(`/admin/strm/uploads/${id}/cancel`).then((r) => r.data),
retryUpload: (id: string) =>
api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data),
}
+10
View File
@@ -26,6 +26,13 @@ const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
)
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })))
const StrmManagePage = lazy(() => import('./pages/StrmManagePage').then((m) => ({ default: m.StrmManagePage })))
const StrmDownloadQueuePage = lazy(() =>
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmDownloadQueuePage })),
)
const StrmUploadQueuePage = lazy(() =>
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
)
export type AppRoute = {
path?: string
@@ -52,5 +59,8 @@ export const appRoutes: AppRoute[] = [
{ path: 'tools', element: <Navigate to="/files" replace /> },
{ path: 'files', element: <FileManagerPage />, adminOnly: true },
{ path: 'settings', element: <SettingsPage />, adminOnly: true },
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
{ path: 'admin', element: <AdminPage />, adminOnly: true },
]
+7
View File
@@ -1,8 +1,11 @@
import type { LucideIcon } from 'lucide-react'
import {
Download,
FileOutput,
FolderOpen,
Library,
Settings,
Upload,
User,
Users,
} from 'lucide-react'
@@ -22,4 +25,8 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
// STRM 管理(设置区):网盘目录生成 strm 与元数据下载/上传队列
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
{ to: '/strm/downloads', label: '下载队列', icon: Download, adminOnly: true },
{ to: '/strm/uploads', label: '上传队列', icon: Upload, adminOnly: true },
]
File diff suppressed because it is too large Load Diff
+551
View File
@@ -0,0 +1,551 @@
/* eslint-disable react-refresh/only-export-components */
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
Ban,
Cloud,
Download,
FolderPlus,
HardDrive,
History,
Loader2,
Pencil,
Play,
Plus,
RefreshCw,
Settings,
Trash2,
Upload,
} from 'lucide-react'
import { strmAPI } from '../api/strm'
import { confirmAction } from '../components/confirmAction'
import type { StrmAccount, StrmSyncPath, StrmSyncRecord } from '../types/strm'
import { STRM_PROVIDER_LABELS, type StrmProvider } from '../types/strm'
import { StrmAccountDialog, StrmSettingsDialog, StrmSyncPathDialog } from './StrmDialogs'
type StrmDialogKey = 'account' | 'settings' | 'path' | null
const STATUS_META: Record<string, { label: string; cls: string }> = {
idle: { label: '未同步', cls: 'bg-gray-100 text-gray-500' },
running: { label: '同步中', cls: 'bg-brand-100 text-brand-600' },
ok: { label: '正常', cls: 'bg-emerald-100 text-emerald-600' },
error: { label: '失败', cls: 'bg-rose-100 text-rose-600' },
canceled: { label: '已取消', cls: 'bg-amber-100 text-amber-600' },
}
const RECORD_STATUS_META: Record<string, { label: string; cls: string }> = {
pending: { label: '排队中', cls: 'bg-gray-100 text-gray-500' },
running: { label: '进行中', cls: 'bg-brand-100 text-brand-600' },
done: { label: '完成', cls: 'bg-emerald-100 text-emerald-600' },
failed: { label: '失败', cls: 'bg-rose-100 text-rose-600' },
canceled: { label: '已取消', cls: 'bg-amber-100 text-amber-600' },
}
const iconButtonCls =
'inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1 text-xs font-semibold text-ink-100 transition hover:bg-gray-50'
export function providerIcon(provider: StrmProvider) {
switch (provider) {
case 'cloud115':
return Cloud
case 'clouddrive2':
return HardDrive
case 'openlist':
return FolderPlus
case 'local':
return HardDrive
}
}
export function StrmManagePage() {
const [accounts, setAccounts] = useState<StrmAccount[]>([])
const [paths, setPaths] = useState<StrmSyncPath[]>([])
const [records, setRecords] = useState<StrmSyncRecord[]>([])
const [loading, setLoading] = useState(true)
const [dialog, setDialog] = useState<StrmDialogKey>(null)
const [editingPath, setEditingPath] = useState<StrmSyncPath | null>(null)
const [editingAccount, setEditingAccount] = useState<StrmAccount | null>(null)
const [actingPath, setActingPath] = useState<string | null>(null)
const refresh = useCallback(async () => {
try {
const [accts, pths, recs] = await Promise.all([
strmAPI.listAccounts(),
strmAPI.listPaths(),
strmAPI.listRecords(),
])
setAccounts(accts)
setPaths(pths)
setRecords(recs)
} catch {
/* 保留旧数据 */
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh().catch(() => undefined)
}, [refresh])
// 有进行中的同步时轮询刷新状态
useEffect(() => {
if (!paths.some((p) => p.last_sync_status === 'running')) return
const timer = setInterval(() => refresh().catch(() => undefined), 4000)
return () => clearInterval(timer)
}, [paths, refresh])
const startSync = async (path: StrmSyncPath) => {
setActingPath(path.id)
try {
await strmAPI.startSync(path.id)
toast.success(`已开始同步「${path.name}」`)
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setActingPath(null)
}
}
const cancelSync = async (path: StrmSyncPath) => {
try {
await strmAPI.cancelSync(path.id)
toast.success('已请求取消同步')
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const deletePath = async (path: StrmSyncPath) => {
const ok = await confirmAction({ message: `确定删除同步目录「${path.name}」?本地已生成的 strm 文件不会删除。`, confirmText: '删除' })
if (!ok) return
try {
await strmAPI.deletePath(path.id)
toast.success('已删除同步目录')
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const deleteAccount = async (account: StrmAccount) => {
const ok = await confirmAction({ message: `确定删除网盘账号「${account.name}」?`, confirmText: '删除' })
if (!ok) return
try {
await strmAPI.deleteAccount(account.id)
toast.success('已删除网盘账号')
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const testAccount = async (account: StrmAccount) => {
try {
const updated = await strmAPI.testAccount(account.id)
setAccounts((list) => list.map((a) => (a.id === updated.id ? updated : a)))
if (updated.last_test_ok) {
toast.success(`「${account.name}」连接正常`)
} else {
toast.error(`「${account.name}」连接失败:${updated.last_test_result}`)
}
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-sand-300/40 text-ink-100">
<Play size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">STRM 管理</h1>
<p className="text-sm text-ink-50">
网盘目录同步生成 .strm 文件(Emby / Jellyfin 直接刮削播放),元数据经下载 / 上传队列双向同步
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => { setEditingAccount(null); setDialog('account') }} className="neon-button">
<Plus size={16} />
添加网盘账号
</button>
<button type="button" onClick={() => setDialog('settings')} className="neon-button">
<Settings size={16} />
STRM 设置
</button>
<button type="button" onClick={() => { setEditingPath(null); setDialog('path') }} className="neon-button">
<FolderPlus size={16} />
添加同步目录
</button>
</div>
</header>
{loading ? (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
) : (
<>
<AccountSection
accounts={accounts}
onAdd={() => { setEditingAccount(null); setDialog('account') }}
onEdit={(account) => { setEditingAccount(account); setDialog('account') }}
onDelete={deleteAccount}
onTest={testAccount}
/>
<SyncPathSection
paths={paths}
actingPath={actingPath}
onAdd={() => { setEditingPath(null); setDialog('path') }}
onEdit={(path) => { setEditingPath(path); setDialog('path') }}
onDelete={deletePath}
onStart={startSync}
onCancel={cancelSync}
/>
<RecordSection records={records} />
</>
)}
{dialog === 'account' && (
<StrmAccountDialog
existing={editingAccount}
onClose={() => setDialog(null)}
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
/>
)}
{dialog === 'settings' && <StrmSettingsDialog onClose={() => setDialog(null)} />}
{dialog === 'path' && (
<StrmSyncPathDialog
accounts={accounts}
existing={editingPath}
onClose={() => setDialog(null)}
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
/>
)}
</div>
)
}
// ─── 网盘账号 ────────────────────────────────────────────────────────────────
function AccountSection({
accounts,
onAdd,
onEdit,
onDelete,
onTest,
}: {
accounts: StrmAccount[]
onAdd: () => void
onEdit: (account: StrmAccount) => void
onDelete: (account: StrmAccount) => void
onTest: (account: StrmAccount) => void
}) {
return (
<section className="glass-panel space-y-3 p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cloud size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">网盘账号</h2>
</div>
<button type="button" onClick={onAdd} className="text-sm font-semibold text-brand-500 hover:text-brand-600">
+ 新建
</button>
</div>
{accounts.length === 0 ? (
<p className="rounded-xl bg-gray-50 px-4 py-6 text-center text-sm text-sand-500">
还没有网盘账号,点击「添加网盘账号」创建(115 网盘支持二维码登录)
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{accounts.map((account) => {
const Icon = providerIcon(account.provider)
return (
<div key={account.id} className="rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-50 text-brand-500">
<Icon size={18} />
</div>
<div>
<p className="font-semibold text-ink-600">{account.name}</p>
<p className="text-xs text-sand-500">{account.provider_label}</p>
</div>
</div>
<span
className={
'rounded-full px-2 py-0.5 text-[11px] font-semibold ' +
(account.enabled ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-200 text-gray-500')
}
>
{account.enabled ? '已启用' : '已停用'}
</span>
</div>
<div className="mt-3 space-y-1 text-xs text-ink-50">
<p>
凭据:
{account.has_credential ? (
<span className="text-emerald-600">已配置</span>
) : (
<span className="text-gray-400">未配置</span>
)}
</p>
{account.last_test_at && (
<p className={account.last_test_ok ? 'text-emerald-600' : 'text-rose-500'}>
最近测试:{account.last_test_ok ? '正常' : account.last_test_result}
</p>
)}
</div>
<div className="mt-3 flex items-center gap-1.5">
<button type="button" onClick={() => onTest(account)} className={`${iconButtonCls}`}>
<RefreshCw size={14} />
测试
</button>
<button type="button" onClick={() => onEdit(account)} className={`${iconButtonCls}`}>
<Pencil size={14} />
编辑
</button>
<button type="button" onClick={() => onDelete(account)} className={`${iconButtonCls} text-rose-500`}>
<Trash2 size={14} />
删除
</button>
</div>
</div>
)
})}
</div>
)}
</section>
)
}
// ─── STRM 同步目录(参考 QMediaSync 的 STRM 同步目录页) ─────────────────────
function SyncPathSection({
paths,
actingPath,
onAdd,
onEdit,
onDelete,
onStart,
onCancel,
}: {
paths: StrmSyncPath[]
actingPath: string | null
onAdd: () => void
onEdit: (path: StrmSyncPath) => void
onDelete: (path: StrmSyncPath) => void
onStart: (path: StrmSyncPath) => void
onCancel: (path: StrmSyncPath) => void
}) {
return (
<section className="glass-panel space-y-3 p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FolderPlus size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">STRM 同步目录</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] text-sand-500">{paths.length}</span>
</div>
<button type="button" onClick={onAdd} className="text-sm font-semibold text-brand-500 hover:text-brand-600">
+ 新建
</button>
</div>
{paths.length === 0 ? (
<p className="rounded-xl bg-gray-50 px-4 py-6 text-center text-sm text-sand-500">
还没有同步目录。添加后系统会把网盘 / 本地目录里的视频生成 .strm 文件到本地输出目录
</p>
) : (
<div className="space-y-2.5">
{paths.map((path) => {
const status = STATUS_META[path.last_sync_status] ?? STATUS_META.idle
const running = path.last_sync_status === 'running'
const PathIcon = providerIcon(path.provider)
return (
<div key={path.id} className="rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-500">
<PathIcon size={18} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-semibold text-ink-600">
{path.name}
{!path.enabled && <span className="ml-2 text-xs text-gray-400">(已停用)</span>}
</p>
<span className={'rounded-full px-2 py-0.5 text-[11px] font-semibold ' + status.cls}>
{running && <Loader2 size={10} className="mr-0.5 inline animate-spin" />}
{status.label}
</span>
</div>
<p className="mt-0.5 text-xs text-sand-500">
{STRM_PROVIDER_LABELS[path.provider]}
{path.account_name ? ` · ${path.account_name}` : ''}
{path.enable_cron && path.cron ? ` · 定时 ${path.cron}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{running ? (
<button type="button" onClick={() => onCancel(path)} className={`${iconButtonCls} text-amber-600`}>
<Ban size={14} />
取消
</button>
) : (
<button
type="button"
disabled={actingPath === path.id || !path.enabled}
onClick={() => onStart(path)}
className={`${iconButtonCls} disabled:opacity-40`}
>
{actingPath === path.id ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
立即同步
</button>
)}
<button type="button" onClick={() => onEdit(path)} className={`${iconButtonCls}`}>
<Pencil size={14} />
编辑
</button>
<button type="button" onClick={() => onDelete(path)} className={`${iconButtonCls} text-rose-500`}>
<Trash2 size={14} />
删除
</button>
</div>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-ink-50">
<span className="max-w-[45%] truncate">{path.remote_path || '(根目录)'}</span>
<span className="text-sand-500">→</span>
<span className="max-w-[45%] truncate">{path.local_path}</span>
</div>
{path.last_sync_message && (
<p className="mt-1.5 text-xs text-sand-500">{path.last_sync_message}</p>
)}
</div>
)
})}
</div>
)}
</section>
)
}
// ─── 同步记录 ────────────────────────────────────────────────────────────────
function RecordSection({ records }: { records: StrmSyncRecord[] }) {
return (
<section className="glass-panel space-y-3 p-5">
<div className="flex items-center gap-2">
<History size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">同步记录</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] text-sand-500">{records.length}</span>
</div>
{records.length === 0 ? (
<p className="rounded-xl bg-gray-50 px-4 py-6 text-center text-sm text-sand-500">还没有同步记录</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="px-3 py-2">时间</th>
<th className="px-3 py-2">状态</th>
<th className="px-3 py-2 text-right">扫描文件</th>
<th className="px-3 py-2 text-right">新增 strm</th>
<th className="px-3 py-2 text-right">下载元数据</th>
<th className="px-3 py-2 text-right">清理</th>
<th className="px-3 py-2">说明</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const meta = RECORD_STATUS_META[record.status] ?? RECORD_STATUS_META.pending
return (
<tr key={record.id} className="border-t border-gray-100">
<td className="whitespace-nowrap px-3 py-2 text-xs text-ink-50">
{formatTime(record.started_at ?? record.created_at)}
</td>
<td className="px-3 py-2">
<span className={'rounded-full px-2 py-0.5 text-[11px] font-semibold ' + meta.cls}>
{meta.label}
</span>
</td>
<td className="px-3 py-2 text-right">{record.total}</td>
<td className="px-3 py-2 text-right text-brand-500">{record.new_strm}</td>
<td className="px-3 py-2 text-right">{record.new_meta}</td>
<td className="px-3 py-2 text-right">{record.pruned}</td>
<td className="max-w-[260px] truncate px-3 py-2 text-xs text-sand-500">{record.message}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
)
}
export function formatTime(value?: string | null): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
export function apiErrorMessage(err: unknown): string {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
return msg ?? '操作失败'
}
export function formatBytes(size: number): string {
if (!size) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = size
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}`
}
// 队列概览小组件(下载队列 / 上传队列页顶部统计)
export function QueueStatPill({ icon: Icon, label, value }: { icon: typeof Download; label: string; value: number }) {
return (
<div className="flex items-center gap-2 rounded-xl border border-gray-100 bg-white px-3 py-2 shadow-sm">
<Icon size={16} className="text-brand-500" />
<span className="text-xs text-sand-500">{label}</span>
<span className="font-display text-lg font-bold text-ink-600">{value}</span>
</div>
)
}
// 队列状态徽章
export function taskStatusMeta(status: string): { label: string; cls: string } {
switch (status) {
case 'pending':
return { label: '排队中', cls: 'bg-gray-100 text-gray-500' }
case 'running':
return { label: '进行中', cls: 'bg-brand-100 text-brand-600' }
case 'done':
return { label: '已完成', cls: 'bg-emerald-100 text-emerald-600' }
case 'failed':
return { label: '失败', cls: 'bg-rose-100 text-rose-600' }
case 'canceled':
return { label: '已取消', cls: 'bg-amber-100 text-amber-600' }
default:
return { label: status, cls: 'bg-gray-100 text-gray-500' }
}
}
// 供下载/上传队列页复用
export { Upload as UploadIcon }
+327
View File
@@ -0,0 +1,327 @@
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Ban, Download, Loader2, RefreshCw, Trash2, Upload } from 'lucide-react'
import { strmAPI } from '../api/strm'
import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm'
import { STRM_PROVIDER_LABELS } from '../types/strm'
import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage'
const FILTERS: { key: 'all' | StrmTaskStatus; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '排队中' },
{ key: 'running', label: '进行中' },
{ key: 'done', label: '已完成' },
{ key: 'failed', label: '失败' },
{ key: 'canceled', label: '已取消' },
]
const PAGE_SIZE = 50
function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
const [snapshot, setSnapshot] = useState<StrmQueueSnapshot | null>(null)
const [filter, setFilter] = useState<'all' | StrmTaskStatus>('all')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [loading, setLoading] = useState(true)
const [batchBusy, setBatchBusy] = useState(false)
const refresh = useCallback(async () => {
try {
const status = filter === 'all' ? undefined : filter
const data =
kind === 'download'
? await strmAPI.downloads(status, page, PAGE_SIZE)
: await strmAPI.uploads(status, page, PAGE_SIZE)
const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE))
if (page > tp) {
// 当前页超出范围(数据被批量删除),回退到最后一页再刷新
setPage(tp)
return
}
setTotalPages(tp)
setSnapshot(data)
} catch {
/* keep last data */
} finally {
setLoading(false)
}
}, [kind, filter, page])
useEffect(() => {
refresh().catch(() => undefined)
const timer = setInterval(() => refresh().catch(() => undefined), 3000)
return () => clearInterval(timer)
}, [refresh])
const cancelTask = async (task: StrmTask) => {
try {
if (kind === 'download') await strmAPI.cancelDownload(task.id)
else await strmAPI.cancelUpload(task.id)
toast.success('已取消任务')
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const retryTask = async (task: StrmTask) => {
try {
if (kind === 'download') await strmAPI.retryDownload(task.id)
else await strmAPI.retryUpload(task.id)
toast.success('已重新入队')
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
// 批量操作:可选确认弹窗,操作中锁定按钮,结束后刷新。
const runBatch = async (
action: () => Promise<{ deleted?: number; retried?: number; canceled?: number }>,
confirmMsg?: string,
) => {
if (confirmMsg && !window.confirm(confirmMsg)) return
setBatchBusy(true)
try {
const res = await action()
if (res.deleted !== undefined) toast.success(`已清空 ${res.deleted} 条记录`)
else if (res.retried !== undefined) toast.success(`已重新入队 ${res.retried} 个任务`)
else if (res.canceled !== undefined) toast.success(`已取消 ${res.canceled} 个任务`)
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setBatchBusy(false)
}
}
const batchBtn = (title: string, cls: string, onClick: () => void) => (
<button
type="button"
disabled={batchBusy}
onClick={onClick}
className={
'ml-auto inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-xs font-semibold transition disabled:opacity-50 ' + cls
}
>
<Trash2 size={12} />
{title}
</button>
)
const batchActionByFilter = () => {
if (!isDownload) return null
if (filter === 'done')
return batchBtn(
'清空成功记录',
'border-gray-200 text-rose-500 hover:bg-rose-50',
() => runBatch(() => strmAPI.clearDoneDownloads(), '确定清空所有已完成下载记录?'),
)
if (filter === 'failed')
return batchBtn('批量重试', 'border-gray-200 text-brand-500 hover:bg-brand-50', () =>
runBatch(() => strmAPI.retryFailedDownloads(), '确定重新入队所有失败下载任务?'),
)
if (filter === 'pending')
return batchBtn(
'批量取消',
'border-gray-200 text-amber-600 hover:bg-amber-50',
() => runBatch(() => strmAPI.cancelPendingDownloads(), '确定取消所有排队中的下载任务?'),
)
return null
}
const counts = snapshot?.counts
const tasks = snapshot?.tasks.filter((t) => filter === 'all' || t.status === filter) ?? []
const isDownload = kind === 'download'
const Icon = isDownload ? Download : Upload
return (
<div className="space-y-5">
<header className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-sand-300/40 text-ink-100">
<Icon size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">
{isDownload ? '下载队列' : '上传队列'}
</h1>
<p className="text-sm text-ink-50">
{isDownload
? 'STRM 元数据下载情况(远端网盘 → 本地输出目录,3 秒自动刷新)'
: 'STRM 元数据上传情况(本地 → 远端网盘,3 秒自动刷新)'}
</p>
</div>
{isDownload && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runBatch(() => strmAPI.clearFinishedDownloads(), '确定清空所有失败和成功的下载记录?')
}
className="inline-flex items-center gap-1.5 rounded-xl border border-rose-200 px-3 py-2 text-sm font-semibold text-rose-500 transition hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={14} />
清空失败与完成记录
</button>
)}
<button type="button" onClick={refresh} className="ml-auto inline-flex items-center gap-1.5 rounded-xl border border-gray-200 px-3 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
<RefreshCw size={14} />
刷新
</button>
</header>
<div className="flex flex-wrap items-center gap-2">
<StatPill label="排队中" value={counts?.pending ?? 0} cls="text-gray-600" />
<StatPill label="进行中" value={counts?.running ?? 0} cls="text-brand-500" />
<StatPill label="已完成" value={counts?.done ?? 0} cls="text-emerald-600" />
<StatPill label="失败" value={counts?.failed ?? 0} cls="text-rose-500" />
<StatPill label="已取消" value={counts?.canceled ?? 0} cls="text-amber-600" />
</div>
<div className="flex gap-1 overflow-x-auto border-b border-gray-200">
{FILTERS.map((item) => (
<button
key={item.key}
type="button"
onClick={() => {
setFilter(item.key)
setPage(1)
}}
className={
'border-b-2 px-3 py-2 text-sm whitespace-nowrap transition ' +
(filter === item.key ? 'border-primary-400 text-brand-500' : 'border-transparent text-ink-50 hover:text-white')
}
>
{item.label}
</button>
))}
{batchActionByFilter()}
</div>
<div className="glass-panel overflow-hidden">
{loading ? (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
) : tasks.length === 0 ? (
<p className="py-12 text-center text-sm text-sand-500">
{filter === 'all' ? (isDownload ? '暂无元数据下载任务' : '暂无元数据上传任务') : '该状态下暂无任务'}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="px-4 py-3">文件</th>
<th className="px-4 py-3">提供方</th>
<th className="px-4 py-3">{isDownload ? '本地目标' : '本地来源'}</th>
<th className="px-4 py-3">远端路径</th>
<th className="px-4 py-3 text-right">大小</th>
<th className="px-4 py-3">状态</th>
<th className="px-4 py-3">创建时间</th>
<th className="px-4 py-3 text-right">操作</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => {
const status = taskStatusMeta(task.status)
return (
<tr key={task.id} className="border-t border-gray-200 transition hover:bg-gray-50">
<td className="max-w-[220px] truncate px-4 py-2.5 font-medium text-ink-600">{task.file_name}</td>
<td className="px-4 py-2.5 text-xs">
{STRM_PROVIDER_LABELS[task.provider] ?? task.provider}
{task.retry_count > 0 && (
<span className="ml-1 text-[10px] text-gray-400">重试 {task.retry_count}</span>
)}
</td>
<td className="max-w-[200px] truncate px-4 py-2.5 font-mono text-xs text-ink-50">{task.local_path}</td>
<td className="max-w-[200px] truncate px-4 py-2.5 font-mono text-xs text-ink-50">{task.remote_path}</td>
<td className="px-4 py-2.5 text-right text-xs text-ink-50">{formatBytes(task.size)}</td>
<td className="px-4 py-2.5">
<span className={'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ' + status.cls}>
{task.status === 'running' && <Loader2 size={10} className="animate-spin" />}
{status.label}
</span>
{task.error && (
<p className="mt-0.5 max-w-[220px] truncate text-[11px] text-rose-500" title={task.error}>
{task.error}
</p>
)}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-xs text-ink-50">{formatTime(task.created_at)}</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right">
{(task.status === 'pending' || task.status === 'running') && (
<button
type="button"
onClick={() => cancelTask(task)}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1 text-xs font-semibold text-amber-600 transition hover:bg-amber-50"
>
<Ban size={12} />
取消
</button>
)}
{(task.status === 'failed' || task.status === 'canceled') && (
<button
type="button"
onClick={() => retryTask(task)}
className="ml-1 inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1 text-xs font-semibold text-brand-500 transition hover:bg-brand-50"
>
<RefreshCw size={12} />
重试
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{(snapshot?.total ?? 0) > 0 && (
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-3">
<span className="text-xs text-ink-50">
共 {snapshot?.total ?? 0} 条 · 第 {page} / {totalPages} 页
</span>
<div className="flex items-center gap-2">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="inline-flex items-center rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40"
>
上一页
</button>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => p + 1)}
className="inline-flex items-center rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40"
>
下一页
</button>
</div>
</div>
)}
</div>
</div>
)
}
function StatPill({ label, value, cls }: { label: string; value: number; cls: string }) {
return (
<div className="flex items-center gap-2 rounded-xl border border-gray-100 bg-white px-3 py-2 shadow-sm">
<span className="text-xs text-sand-500">{label}</span>
<span className={'font-display text-lg font-bold ' + cls}>{value}</span>
</div>
)
}
export function StrmDownloadQueuePage() {
return <StrmQueuePanel kind="download" />
}
export function StrmUploadQueuePage() {
return <StrmQueuePanel kind="upload" />
}
+148
View File
@@ -0,0 +1,148 @@
// STRM 管理相关类型定义(与后端 internal/model/strm.go 对应)。
export type StrmProvider = 'cloud115' | 'clouddrive2' | 'openlist' | 'local'
export const STRM_PROVIDER_LABELS: Record<StrmProvider, string> = {
cloud115: '115 网盘',
clouddrive2: 'CloudDrive2',
openlist: 'OpenList',
local: '本地目录',
}
export interface StrmAccount {
id: string
name: string
provider: StrmProvider
enabled: boolean
created_at: string
updated_at: string
last_test_at?: string | null
last_test_result: string
last_test_ok: boolean
has_credential: boolean
provider_label: string
}
export interface StrmAccountInput {
name?: string
provider: StrmProvider
config?: Record<string, string>
enabled?: boolean
}
export type StrmSyncStatus = 'idle' | 'running' | 'ok' | 'error' | 'canceled'
export interface StrmSyncPath {
id: string
name: string
account_id: string
provider: StrmProvider
remote_path: string
local_path: string
strm_base_url: string
video_ext: string
meta_ext: string
exclude_name: string
min_video_size_mb: number
add_path: number
download_meta: boolean
upload_meta: boolean
delete_dir: boolean
cron: string
enable_cron: boolean
enabled: boolean
created_at: string
last_sync_at?: string | null
last_sync_status: StrmSyncStatus
last_sync_message: string
account_name: string
account_enabled: boolean
}
export interface StrmSyncPathInput {
name?: string
account_id?: string
provider: StrmProvider
remote_path: string
local_path: string
strm_base_url?: string
video_ext?: string
meta_ext?: string
exclude_name?: string
min_video_size_mb?: number
add_path?: number
download_meta?: boolean
upload_meta?: boolean
delete_dir?: boolean
cron?: string
enable_cron?: boolean
enabled?: boolean
}
export interface StrmSyncRecord {
id: string
sync_path_id: string
status: 'pending' | 'running' | 'done' | 'failed' | 'canceled'
total: number
new_strm: number
new_meta: number
uploaded: number
pruned: number
skipped: number
message: string
started_at?: string | null
finished_at?: string | null
created_at: string
}
export type StrmTaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'canceled'
export interface StrmTask {
id: string
kind: 'download' | 'upload'
sync_path_id: string
account_id: string
provider: StrmProvider
file_name: string
local_path: string
remote_path: string
size: number
status: StrmTaskStatus
error: string
retry_count: number
created_at: string
started_at?: string | null
finished_at?: string | null
}
export interface StrmQueueCounts {
pending: number
running: number
done: number
failed: number
canceled: number
}
export interface StrmQueueSnapshot {
counts: StrmQueueCounts
tasks: StrmTask[]
total: number // 当前过滤条件下任务总数
page: number // 当前页码(从 1 开始)
page_size: number // 单页大小
}
export interface StrmSettingsMap {
'strm.base_url': string
'strm.video_ext': string
'strm.meta_ext': string
'strm.exclude_name': string
'strm.min_video_size_mb': string
'strm.add_path': string
'strm.download_meta': string
'strm.upload_meta': string
'strm.delete_dir': string
'strm.download_threads': string
'strm.upload_threads': string
[key: string]: string
}
export type StrmTaskStatusFilter = 'all' | StrmTaskStatus