mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
添加strm生成
添加strm
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -51,5 +51,10 @@ func AllModels() []interface{} {
|
||||
&RegistrationCode{},
|
||||
&SignIn{},
|
||||
&UserDevice{},
|
||||
&StrmAccount{},
|
||||
&StrmSyncPath{},
|
||||
&StrmSyncRecord{},
|
||||
&StrmDownloadTask{},
|
||||
&StrmUploadTask{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Generated
+325
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user