From c55d29ae84d97dc5f80dc312f272e7808a811700 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:36:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0strm=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加strm --- internal/handler/handler.go | 6 + internal/handler/routes_admin.go | 36 + internal/handler/strm.go | 564 ++++++++ internal/handler/strm_routes_test.go | 102 ++ internal/model/model.go | 5 + internal/model/strm.go | 125 ++ internal/repository/repository.go | 10 + internal/repository/strm_repository.go | 438 ++++++ internal/service/cloud/cloud.go | 138 ++ internal/service/cloud/cloud_115_test.go | 134 ++ internal/service/cloud/cloud_test.go | 25 + internal/service/cloud/clouddrive2.go | 237 +++ internal/service/cloud/clouddrive2_dav.go | 172 +++ .../service/cloud/clouddrive2_dav_error.go | 55 + .../service/cloud/clouddrive2_dav_path.go | 85 ++ .../service/cloud/clouddrive2_mutation.go | 312 ++++ .../service/cloud/clouddrive2_openlist.go | 240 ++++ .../cloud/clouddrive2_openlist_helpers.go | 126 ++ internal/service/cloud/clouddrive2_test.go | 171 +++ internal/service/cloud/openlist_list_test.go | 280 ++++ .../service/cloud/openlist_resolve_test.go | 204 +++ internal/service/cloud/pan115_openapi.go | 94 ++ internal/service/cloud115/client.go | 238 ++++ internal/service/cloud115/cloud115_test.go | 323 +++++ internal/service/cloud115/const.go | 97 ++ internal/service/cloud115/oauth.go | 468 ++++++ internal/service/cloud115/open.go | 326 +++++ internal/service/cloud115/source.go | 1266 +++++++++++++++++ internal/service/cloud115/utils.go | 14 + internal/service/service.go | 13 +- internal/service/service_builder.go | 3 +- internal/service/strm_115_oauth.go | 421 ++++++ internal/service/strm_115_oauth_test.go | 228 +++ internal/service/strm_play.go | 143 ++ internal/service/strm_queue.go | 499 +++++++ internal/service/strm_service.go | 670 +++++++++ internal/service/strm_sync.go | 798 +++++++++++ internal/service/strm_sync_test.go | 204 +++ web/package-lock.json | 326 ++++- web/package.json | 2 + web/src/api/strm.ts | 163 +++ web/src/appRoutes.tsx | 10 + web/src/components/layoutNavigation.ts | 7 + web/src/pages/StrmDialogs.tsx | 1156 +++++++++++++++ web/src/pages/StrmManagePage.tsx | 551 +++++++ web/src/pages/StrmQueuePage.tsx | 327 +++++ web/src/types/strm.ts | 148 ++ 47 files changed, 11956 insertions(+), 4 deletions(-) create mode 100644 internal/handler/strm.go create mode 100644 internal/handler/strm_routes_test.go create mode 100644 internal/model/strm.go create mode 100644 internal/repository/strm_repository.go create mode 100644 internal/service/cloud/cloud.go create mode 100644 internal/service/cloud/cloud_115_test.go create mode 100644 internal/service/cloud/cloud_test.go create mode 100644 internal/service/cloud/clouddrive2.go create mode 100644 internal/service/cloud/clouddrive2_dav.go create mode 100644 internal/service/cloud/clouddrive2_dav_error.go create mode 100644 internal/service/cloud/clouddrive2_dav_path.go create mode 100644 internal/service/cloud/clouddrive2_mutation.go create mode 100644 internal/service/cloud/clouddrive2_openlist.go create mode 100644 internal/service/cloud/clouddrive2_openlist_helpers.go create mode 100644 internal/service/cloud/clouddrive2_test.go create mode 100644 internal/service/cloud/openlist_list_test.go create mode 100644 internal/service/cloud/openlist_resolve_test.go create mode 100644 internal/service/cloud/pan115_openapi.go create mode 100644 internal/service/cloud115/client.go create mode 100644 internal/service/cloud115/cloud115_test.go create mode 100644 internal/service/cloud115/const.go create mode 100644 internal/service/cloud115/oauth.go create mode 100644 internal/service/cloud115/open.go create mode 100644 internal/service/cloud115/source.go create mode 100644 internal/service/cloud115/utils.go create mode 100644 internal/service/strm_115_oauth.go create mode 100644 internal/service/strm_115_oauth_test.go create mode 100644 internal/service/strm_play.go create mode 100644 internal/service/strm_queue.go create mode 100644 internal/service/strm_service.go create mode 100644 internal/service/strm_sync.go create mode 100644 internal/service/strm_sync_test.go create mode 100644 web/src/api/strm.ts create mode 100644 web/src/pages/StrmDialogs.tsx create mode 100644 web/src/pages/StrmManagePage.tsx create mode 100644 web/src/pages/StrmQueuePage.tsx create mode 100644 web/src/types/strm.ts diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 77ddbed..71e703a 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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) diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index bd1f5e8..90bdce4 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -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) { diff --git a/internal/handler/strm.go b/internal/handler/strm.go new file mode 100644 index 0000000..01eee6b --- /dev/null +++ b/internal/handler/strm.go @@ -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) + } +} diff --git a/internal/handler/strm_routes_test.go b/internal/handler/strm_routes_test.go new file mode 100644 index 0000000..416a9d1 --- /dev/null +++ b/internal/handler/strm_routes_test.go @@ -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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 794bc19..f9f5760 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -51,5 +51,10 @@ func AllModels() []interface{} { &RegistrationCode{}, &SignIn{}, &UserDevice{}, + &StrmAccount{}, + &StrmSyncPath{}, + &StrmSyncRecord{}, + &StrmDownloadTask{}, + &StrmUploadTask{}, } } diff --git a/internal/model/strm.go b/internal/model/strm.go new file mode 100644 index 0000000..709de50 --- /dev/null +++ b/internal/model/strm.go @@ -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"` +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 08220db..99cb43e 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -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}, } } diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go new file mode 100644 index 0000000..aaae9bd --- /dev/null +++ b/internal/repository/strm_repository.go @@ -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 +} diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go new file mode 100644 index 0000000..8c28b63 --- /dev/null +++ b/internal/service/cloud/cloud.go @@ -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" diff --git a/internal/service/cloud/cloud_115_test.go b/internal/service/cloud/cloud_115_test.go new file mode 100644 index 0000000..0f4d9a7 --- /dev/null +++ b/internal/service/cloud/cloud_115_test.go @@ -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 +} \ No newline at end of file diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go new file mode 100644 index 0000000..ff0abc1 --- /dev/null +++ b/internal/service/cloud/cloud_test.go @@ -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") + } +} diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go new file mode 100644 index 0000000..64ddfa6 --- /dev/null +++ b/internal/service/cloud/clouddrive2.go @@ -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 "" +} diff --git a/internal/service/cloud/clouddrive2_dav.go b/internal/service/cloud/clouddrive2_dav.go new file mode 100644 index 0000000..042946c --- /dev/null +++ b/internal/service/cloud/clouddrive2_dav.go @@ -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) + } +} diff --git a/internal/service/cloud/clouddrive2_dav_error.go b/internal/service/cloud/clouddrive2_dav_error.go new file mode 100644 index 0000000..6ae5372 --- /dev/null +++ b/internal/service/cloud/clouddrive2_dav_error.go @@ -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 +} diff --git a/internal/service/cloud/clouddrive2_dav_path.go b/internal/service/cloud/clouddrive2_dav_path.go new file mode 100644 index 0000000..14837c4 --- /dev/null +++ b/internal/service/cloud/clouddrive2_dav_path.go @@ -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 = ` + + + + + + +` + +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 +} diff --git a/internal/service/cloud/clouddrive2_mutation.go b/internal/service/cloud/clouddrive2_mutation.go new file mode 100644 index 0000000..31ae3d1 --- /dev/null +++ b/internal/service/cloud/clouddrive2_mutation.go @@ -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) +} diff --git a/internal/service/cloud/clouddrive2_openlist.go b/internal/service/cloud/clouddrive2_openlist.go new file mode 100644 index 0000000..6c4e2f4 --- /dev/null +++ b/internal/service/cloud/clouddrive2_openlist.go @@ -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 +} diff --git a/internal/service/cloud/clouddrive2_openlist_helpers.go b/internal/service/cloud/clouddrive2_openlist_helpers.go new file mode 100644 index 0000000..d5d1880 --- /dev/null +++ b/internal/service/cloud/clouddrive2_openlist_helpers.go @@ -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) +} diff --git a/internal/service/cloud/clouddrive2_test.go b/internal/service/cloud/clouddrive2_test.go new file mode 100644 index 0000000..78cb264 --- /dev/null +++ b/internal/service/cloud/clouddrive2_test.go @@ -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(` + + + /dav/ + + + + /dav/115/ + 115 + + + /dav/123/Movie.mkv + Movie.mkv789 + +`)) + 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(`/dav/`)) + 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) + } +} diff --git a/internal/service/cloud/openlist_list_test.go b/internal/service/cloud/openlist_list_test.go new file mode 100644 index 0000000..7dd6164 --- /dev/null +++ b/internal/service/cloud/openlist_list_test.go @@ -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(` + + + /dav/ + + + + /dav/Cloud/Movie.mkv + Movie.mkv1024 + +`)) + })) + 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(`/dav/`)) + })) + 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) + } +} diff --git a/internal/service/cloud/openlist_resolve_test.go b/internal/service/cloud/openlist_resolve_test.go new file mode 100644 index 0000000..2b9b976 --- /dev/null +++ b/internal/service/cloud/openlist_resolve_test.go @@ -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") + } +} diff --git a/internal/service/cloud/pan115_openapi.go b/internal/service/cloud/pan115_openapi.go new file mode 100644 index 0000000..9855f50 --- /dev/null +++ b/internal/service/cloud/pan115_openapi.go @@ -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) +} \ No newline at end of file diff --git a/internal/service/cloud115/client.go b/internal/service/cloud115/client.go new file mode 100644 index 0000000..fa9c649 --- /dev/null +++ b/internal/service/cloud115/client.go @@ -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{} +} diff --git a/internal/service/cloud115/cloud115_test.go b/internal/service/cloud115/cloud115_test.go new file mode 100644 index 0000000..7048e18 --- /dev/null +++ b/internal/service/cloud115/cloud115_test.go @@ -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") + } +} diff --git a/internal/service/cloud115/const.go b/internal/service/cloud115/const.go new file mode 100644 index 0000000..b070142 --- /dev/null +++ b/internal/service/cloud115/const.go @@ -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[:]) +} diff --git a/internal/service/cloud115/oauth.go b/internal/service/cloud115/oauth.go new file mode 100644 index 0000000..3ec9743 --- /dev/null +++ b/internal/service/cloud115/oauth.go @@ -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 +} diff --git a/internal/service/cloud115/open.go b/internal/service/cloud115/open.go new file mode 100644 index 0000000..31b044a --- /dev/null +++ b/internal/service/cloud115/open.go @@ -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 +} diff --git a/internal/service/cloud115/source.go b/internal/service/cloud115/source.go new file mode 100644 index 0000000..d917177 --- /dev/null +++ b/internal/service/cloud115/source.go @@ -0,0 +1,1266 @@ +// 115 开放平台授权来源:内置应用目录与第三方/中继授权源。 +// 数据与逻辑移植自 QMediaSync(MIT 类项目,https://github.com/chen8945/QMediaSync)。 +package cloud115 + +import "strings" + +type AuthSourceType string + +const ( + SourceTypeBuiltInAppID AuthSourceType = "built_in_appid" + SourceTypeBuiltInRelay AuthSourceType = "built_in_relay" + SourceTypeThirdPartyService AuthSourceType = "third_party_service" + SourceTypeCustomAppID AuthSourceType = "custom_appid" +) + +type AuthProvider string + +const ( + ProviderOfficialPKCE AuthProvider = "official_pkce" + ProviderMQFamily AuthProvider = "mqfamily" + ProviderQMediaSync AuthProvider = "qmediasync" + ProviderMoviePilot AuthProvider = "moviepilot" + ProviderCloudDrive AuthProvider = "clouddrive" +) + +const ( + BuiltInRelayQMediaSync = "QMediaSync" + BuiltInRelayQ115STRM = "Q115-STRM" + BuiltInRelayMQMediaLibrary = "MQ的媒体库" + CustomAppName = "自定义 APP ID" +) + +// Source 描述一种 115 授权来源(应用/服务)。 +type Source struct { + SourceType AuthSourceType `json:"auth_source_type"` + Provider AuthProvider `json:"auth_provider"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + DisplayName string `json:"display_name"` + AuthServer string `json:"auth_server,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + RequiresEncryptionKey bool `json:"requires_encryption_key"` + Pinned bool `json:"pinned"` + Deprecated bool `json:"deprecated"` +} + +type AppIDSearchResult struct { + Items []Source `json:"items"` + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +var builtInAppIDCatalog = []Source{ + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195125", AppName: "媒体播放器", DisplayName: "媒体播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195127", AppName: "蒙太奇播放器", DisplayName: "蒙太奇播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195129", AppName: "飞牛私有云-挂载", DisplayName: "飞牛私有云-挂载"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195131", AppName: "腾飞Webos", DisplayName: "腾飞Webos"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195133", AppName: "恒星播放器", DisplayName: "恒星播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195135", AppName: "网易爆米花", DisplayName: "网易爆米花"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195137", AppName: "VidHub播放器", DisplayName: "VidHub播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195139", AppName: "艾索洛HIFI播放器", DisplayName: "艾索洛HIFI播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195141", AppName: "纵道软件", DisplayName: "纵道软件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195143", AppName: "云上生活", DisplayName: "云上生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195145", AppName: "Filebar", DisplayName: "Filebar"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195147", AppName: "CineUltra", DisplayName: "CineUltra"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195149", AppName: "DS Cloud", DisplayName: "DS Cloud"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195151", AppName: "PurePhoto", DisplayName: "PurePhoto"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195153", AppName: "BoxPlayer", DisplayName: "BoxPlayer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195155", AppName: "Conflux Player", DisplayName: "Conflux Player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195157", AppName: "大学生资源网", DisplayName: "大学生资源网"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195159", AppName: "media saber", DisplayName: "media saber"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195161", AppName: "SenPlayer视频播放器", DisplayName: "SenPlayer视频播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195163", AppName: "可达阅读器", DisplayName: "可达阅读器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195165", AppName: "DMP", DisplayName: "DMP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195167", AppName: "破茧在线", DisplayName: "破茧在线"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195169", AppName: "搜狐影音", DisplayName: "搜狐影音"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195171", AppName: "酷睿视", DisplayName: "酷睿视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195173", AppName: "爱影视", DisplayName: "爱影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195175", AppName: "芝杜", DisplayName: "芝杜"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195177", AppName: "CloudDrive2", DisplayName: "CloudDrive2"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195179", AppName: "yadSci文件管理器", DisplayName: "yadSci文件管理器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195181", AppName: "Infuse", DisplayName: "Infuse"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195183", AppName: "Forward新视界", DisplayName: "Forward新视界"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195185", AppName: "马春杰杰个人博客APP", DisplayName: "马春杰杰个人博客APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195187", AppName: "爱尚137影视", DisplayName: "爱尚137影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195189", AppName: "米卡AI", DisplayName: "米卡AI"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195191", AppName: "合家欢乐", DisplayName: "合家欢乐"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195193", AppName: "绿联云网盘工具", DisplayName: "绿联云网盘工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195195", AppName: "无边记", DisplayName: "无边记"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195197", AppName: "棉花音乐", DisplayName: "棉花音乐"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195199", AppName: "极空间", DisplayName: "极空间"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195201", AppName: "Moon Player", DisplayName: "Moon Player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195203", AppName: "课程表", DisplayName: "课程表"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195205", AppName: "威动极清播放器", DisplayName: "威动极清播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195207", AppName: "一瞬图书", DisplayName: "一瞬图书"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195209", AppName: "影音宝", DisplayName: "影音宝"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195211", AppName: "media saber", DisplayName: "media saber"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195213", AppName: "当贝播放器", DisplayName: "当贝播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195215", AppName: "纸盒仓储管理系统", DisplayName: "纸盒仓储管理系统"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195217", AppName: "南宫市启臻教育集团", DisplayName: "南宫市启臻教育集团"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195219", AppName: "朗新", DisplayName: "朗新"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195221", AppName: "pipi 115", DisplayName: "pipi 115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195223", AppName: "魔方信息技术", DisplayName: "魔方信息技术"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195225", AppName: "115生活app", DisplayName: "115生活app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195227", AppName: "xixi的小站", DisplayName: "xixi的小站"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195229", AppName: "粑屁科技", DisplayName: "粑屁科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195231", AppName: "大朋云", DisplayName: "大朋云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195233", AppName: "nac-syc", DisplayName: "nac-syc"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195235", AppName: "XCMG", DisplayName: "XCMG"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195237", AppName: "孕学堂云存储", DisplayName: "孕学堂云存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195239", AppName: "灵客视频", DisplayName: "灵客视频"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195241", AppName: "章鱼图趣", DisplayName: "章鱼图趣"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195243", AppName: "办公摩的", DisplayName: "办公摩的"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195245", AppName: "幽智", DisplayName: "幽智"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195247", AppName: "闪汇图库", DisplayName: "闪汇图库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195249", AppName: "蜗牛云盘", DisplayName: "蜗牛云盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195251", AppName: "智能相册收集器", DisplayName: "智能相册收集器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195253", AppName: "团鼠记多媒体内容管理平台", DisplayName: "团鼠记多媒体内容管理平台"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195255", AppName: "云盒", DisplayName: "云盒"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195257", AppName: "Envision", DisplayName: "Envision"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195259", AppName: "小白云盘", DisplayName: "小白云盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195261", AppName: "Ryan媒体库", DisplayName: "Ryan媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195263", AppName: "小破喵盒子", DisplayName: "小破喵盒子"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195265", AppName: "果云之FUN", DisplayName: "果云之FUN"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195267", AppName: "聚合云存储客户端", DisplayName: "聚合云存储客户端"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195269", AppName: "Kikoeru", DisplayName: "Kikoeru"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195271", AppName: "飞牛私有云", DisplayName: "飞牛私有云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195273", AppName: "阅微", DisplayName: "阅微"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195275", AppName: "Mediagate", DisplayName: "Mediagate"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195277", AppName: "yadSci", DisplayName: "yadSci"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195279", AppName: "iBackup", DisplayName: "iBackup"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195281", AppName: "vidsCool", DisplayName: "vidsCool"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195283", AppName: "恒星播放器-Android版本", DisplayName: "恒星播放器-Android版本"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195285", AppName: "智存管家", DisplayName: "智存管家"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195287", AppName: "VRTop", DisplayName: "VRTop"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195289", AppName: "混沌影视", DisplayName: "混沌影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195291", AppName: "ai智能投喂助手", DisplayName: "ai智能投喂助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195293", AppName: "Tizzyt", DisplayName: "Tizzyt"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195295", AppName: "rclone修改版", DisplayName: "rclone修改版"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195297", AppName: "AI影视Plus", DisplayName: "AI影视Plus"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195299", AppName: "Lenna", DisplayName: "Lenna"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195301", AppName: "Forward", DisplayName: "Forward"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195303", AppName: "alistgo", DisplayName: "alistgo"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195305", AppName: "极速媒体库Pro", DisplayName: "极速媒体库Pro"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195307", AppName: "115FileSync", DisplayName: "115FileSync"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195309", AppName: "临床指南库", DisplayName: "临床指南库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195313", AppName: "CloudDrive", DisplayName: "CloudDrive"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195315", AppName: "6V电影", DisplayName: "6V电影"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195317", AppName: "爱影科技", DisplayName: "爱影科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195319", AppName: "ONOBOX 小野盒子", DisplayName: "ONOBOX 小野盒子"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195321", AppName: "信号工程平台", DisplayName: "信号工程平台"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195323", AppName: "HiFier", DisplayName: "HiFier"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195325", AppName: "Solfin播放器APP", DisplayName: "Solfin播放器APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195327", AppName: "混元影音", DisplayName: "混元影音"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195329", AppName: "SyncLinker", DisplayName: "SyncLinker"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195331", AppName: "媒体库Pro", DisplayName: "媒体库Pro"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195333", AppName: "xaz115", DisplayName: "xaz115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195335", AppName: "零度小站", DisplayName: "零度小站"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195337", AppName: "云上影视生活", DisplayName: "云上影视生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195339", AppName: "收集文件", DisplayName: "收集文件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195341", AppName: "鸭梨视频", DisplayName: "鸭梨视频"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195343", AppName: "411的115生活", DisplayName: "411的115生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195345", AppName: "茉莉花茶", DisplayName: "茉莉花茶"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195347", AppName: "jxl", DisplayName: "jxl"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195349", AppName: "个人软件", DisplayName: "个人软件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195351", AppName: "长知味", DisplayName: "长知味"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195353", AppName: "容灾备份", DisplayName: "容灾备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195355", AppName: "测试应用", DisplayName: "测试应用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195357", AppName: "普雷创艺互动广告", DisplayName: "普雷创艺互动广告"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195359", AppName: "个人博客站点", DisplayName: "个人博客站点"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195361", AppName: "网站文件存储", DisplayName: "网站文件存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195363", AppName: "家用监控", DisplayName: "家用监控"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195365", AppName: "115媒体直连", DisplayName: "115媒体直连"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195367", AppName: "我的115生活", DisplayName: "我的115生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195369", AppName: "云盘蓝光播放器", DisplayName: "云盘蓝光播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195371", AppName: "网盘工具", DisplayName: "网盘工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195373", AppName: "个人资源备份", DisplayName: "个人资源备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195375", AppName: "无心", DisplayName: "无心"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195377", AppName: "rclone666", DisplayName: "rclone666"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195379", AppName: "仓鼠云同步", DisplayName: "仓鼠云同步"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195381", AppName: "个人nas接入", DisplayName: "个人nas接入"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195383", AppName: "CloudDrive助手", DisplayName: "CloudDrive助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195385", AppName: "家用影音库", DisplayName: "家用影音库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195387", AppName: "infuse或alist等", DisplayName: "infuse或alist等"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195389", AppName: "115开放平台", DisplayName: "115开放平台"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195391", AppName: "MarkdownNote", DisplayName: "MarkdownNote"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195393", AppName: "emby", DisplayName: "emby"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195395", AppName: "私人使用", DisplayName: "私人使用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195397", AppName: "BONPLAYER", DisplayName: "BONPLAYER"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195399", AppName: "顺顺小屋", DisplayName: "顺顺小屋"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195401", AppName: "infunse", DisplayName: "infunse"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195403", AppName: "本地媒体库", DisplayName: "本地媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195405", AppName: "知库", DisplayName: "知库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195407", AppName: "知识存储", DisplayName: "知识存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195409", AppName: "lyyhui媒体库", DisplayName: "lyyhui媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195411", AppName: "部落格同步盘", DisplayName: "部落格同步盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195413", AppName: "Montage ArtWall", DisplayName: "Montage ArtWall"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195415", AppName: "个人云存储助手", DisplayName: "个人云存储助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195417", AppName: "云图床", DisplayName: "云图床"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195419", AppName: "自媒体影音素材库", DisplayName: "自媒体影音素材库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195421", AppName: "个人影音库", DisplayName: "个人影音库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195423", AppName: "vplay", DisplayName: "vplay"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195425", AppName: "konoha", DisplayName: "konoha"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195427", AppName: "XR博客", DisplayName: "XR博客"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195429", AppName: "Media302", DisplayName: "Media302"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195431", AppName: "bestplex", DisplayName: "bestplex"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195433", AppName: "个人学习了解", DisplayName: "个人学习了解"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195435", AppName: "纳兰戏梦", DisplayName: "纳兰戏梦"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195437", AppName: "个人网盘", DisplayName: "个人网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195439", AppName: "云间OSSshare", DisplayName: "云间OSSshare"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195441", AppName: "Projecta", DisplayName: "Projecta"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195443", AppName: "个人使用", DisplayName: "个人使用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195445", AppName: "nplayer", DisplayName: "nplayer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195447", AppName: "SMY", DisplayName: "SMY"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195449", AppName: "电视盒子", DisplayName: "电视盒子"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195451", AppName: "mediaApi", DisplayName: "mediaApi"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195453", AppName: "个人聚合网盘", DisplayName: "个人聚合网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195455", AppName: "storage", DisplayName: "storage"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195457", AppName: "NOZOMI", DisplayName: "NOZOMI"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195459", AppName: "个人云盘", DisplayName: "个人云盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195461", AppName: "dav服务", DisplayName: "dav服务"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195463", AppName: "飓风素材库", DisplayName: "飓风素材库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195465", AppName: "微笑猫博客", DisplayName: "微笑猫博客"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195467", AppName: "战神666", DisplayName: "战神666"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195469", AppName: "生活的远方", DisplayName: "生活的远方"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195471", AppName: "115网盘", DisplayName: "115网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195473", AppName: "家庭中心", DisplayName: "家庭中心"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195475", AppName: "有盘没烦恼", DisplayName: "有盘没烦恼"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195477", AppName: "MediaMaster", DisplayName: "MediaMaster"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195479", AppName: "自建媒体库", DisplayName: "自建媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195481", AppName: "视频作品分享平台", DisplayName: "视频作品分享平台"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195483", AppName: "自动备份客户端", DisplayName: "自动备份客户端"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195485", AppName: "115小幸运", DisplayName: "115小幸运"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195487", AppName: "Rex的应用", DisplayName: "Rex的应用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195489", AppName: "11网盘", DisplayName: "11网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195491", AppName: "网盘内容管理", DisplayName: "网盘内容管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195493", AppName: "Alist看电影", DisplayName: "Alist看电影"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195495", AppName: "Filmix", DisplayName: "Filmix"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195497", AppName: "个人相册云存储分类整理备份", DisplayName: "个人相册云存储分类整理备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195499", AppName: "JarvasNas", DisplayName: "JarvasNas"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195501", AppName: "家庭照片备份", DisplayName: "家庭照片备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195503", AppName: "webdav115", DisplayName: "webdav115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195505", AppName: "文件LLM自动更名", DisplayName: "文件LLM自动更名"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195507", AppName: "文件下载", DisplayName: "文件下载"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195509", AppName: "115cli", DisplayName: "115cli"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195511", AppName: "人云存储", DisplayName: "人云存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195513", AppName: "个人网站服务", DisplayName: "个人网站服务"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195515", AppName: "dxk501", DisplayName: "dxk501"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195517", AppName: "对接 API", DisplayName: "对接 API"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195519", AppName: "EXPSRV", DisplayName: "EXPSRV"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195521", AppName: "115treeSTRM生成工具", DisplayName: "115treeSTRM生成工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195523", AppName: "infuse vidhub", DisplayName: "infuse vidhub"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195525", AppName: "影视相关", DisplayName: "影视相关"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195527", AppName: "一生一世", DisplayName: "一生一世"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195529", AppName: "api", DisplayName: "api"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195531", AppName: "rtsp视频流自动归档上传", DisplayName: "rtsp视频流自动归档上传"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195533", AppName: "家庭影院生活", DisplayName: "家庭影院生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195535", AppName: "矿用急救器使用方法考试系统", DisplayName: "矿用急救器使用方法考试系统"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195537", AppName: "afosne", DisplayName: "afosne"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195539", AppName: "本地个人观影库", DisplayName: "本地个人观影库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195541", AppName: "西瓜聚合网盘", DisplayName: "西瓜聚合网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195543", AppName: "挂载infuse", DisplayName: "挂载infuse"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195545", AppName: "巴巴塔115", DisplayName: "巴巴塔115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195547", AppName: "个人媒体文件管理", DisplayName: "个人媒体文件管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195549", AppName: "超级存储", DisplayName: "超级存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195551", AppName: "网盘", DisplayName: "网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195553", AppName: "个人网盘聚合", DisplayName: "个人网盘聚合"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195555", AppName: "nas对接", DisplayName: "nas对接"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195557", AppName: "appletv访问115", DisplayName: "appletv访问115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195559", AppName: "vidflow", DisplayName: "vidflow"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195561", AppName: "haomeng", DisplayName: "haomeng"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195563", AppName: "家庭多媒体中心", DisplayName: "家庭多媒体中心"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195565", AppName: "家庭影院访问115网盘中的文件", DisplayName: "家庭影院访问115网盘中的文件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195567", AppName: "115个人存储", DisplayName: "115个人存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195569", AppName: "云床", DisplayName: "云床"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195571", AppName: "局域网视频播放", DisplayName: "局域网视频播放"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195573", AppName: "mysite", DisplayName: "mysite"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195575", AppName: "网盘备份", DisplayName: "网盘备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195577", AppName: "RegexV3(内置命名助手)", DisplayName: "RegexV3(内置命名助手)"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195579", AppName: "网站图床和文件、视频储存", DisplayName: "网站图床和文件、视频储存"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195581", AppName: "图床直链", DisplayName: "图床直链"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195583", AppName: "Webdav", DisplayName: "Webdav"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195585", AppName: "叮当个人网盘", DisplayName: "叮当个人网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195587", AppName: "Emby", DisplayName: "Emby"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195589", AppName: "javStrm", DisplayName: "javStrm"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195591", AppName: "alist", DisplayName: "alist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195593", AppName: "muse", DisplayName: "muse"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195595", AppName: "115生活0", DisplayName: "115生活0"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195597", AppName: "肚肚肚", DisplayName: "肚肚肚"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195599", AppName: "个人网站开发", DisplayName: "个人网站开发"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195601", AppName: "ONOBOX", DisplayName: "ONOBOX"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195603", AppName: "DeepRag", DisplayName: "DeepRag"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195605", AppName: "个人自用,家庭媒体库alist", DisplayName: "个人自用,家庭媒体库alist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195607", AppName: "infuse news", DisplayName: "infuse news"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195609", AppName: "115对接", DisplayName: "115对接"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195613", AppName: "115影视", DisplayName: "115影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195615", AppName: "emby-nginx", DisplayName: "emby-nginx"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195617", AppName: "周炯webdav", DisplayName: "周炯webdav"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195619", AppName: "cbmedia", DisplayName: "cbmedia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195621", AppName: "MXCloud", DisplayName: "MXCloud"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195623", AppName: "115自用", DisplayName: "115自用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195625", AppName: "nas自用", DisplayName: "nas自用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195627", AppName: "家庭观影", DisplayName: "家庭观影"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195629", AppName: "媒体管理", DisplayName: "媒体管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195631", AppName: "家里云", DisplayName: "家里云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195633", AppName: "115个人文件管理", DisplayName: "115个人文件管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195635", AppName: "我的网盘", DisplayName: "我的网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195637", AppName: "集成到iPhone自带文件查看", DisplayName: "集成到iPhone自带文件查看"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195639", AppName: "我的视频播放器", DisplayName: "我的视频播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195641", AppName: "个人云存储存取", DisplayName: "个人云存储存取"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195643", AppName: "ES文件浏览器", DisplayName: "ES文件浏览器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195645", AppName: "我的图片库", DisplayName: "我的图片库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195647", AppName: "幺幺舞", DisplayName: "幺幺舞"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195649", AppName: "zhenglibin_115", DisplayName: "zhenglibin_115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195651", AppName: "网页内容直存115", DisplayName: "网页内容直存115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195653", AppName: "还没想好", DisplayName: "还没想好"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195655", AppName: "115movie", DisplayName: "115movie"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195657", AppName: "jvideo", DisplayName: "jvideo"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195659", AppName: "自己的NAS", DisplayName: "自己的NAS"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195661", AppName: "115视频播放", DisplayName: "115视频播放"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195663", AppName: "科劳德素材剪辑库", DisplayName: "科劳德素材剪辑库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195665", AppName: "115%Nas", DisplayName: "115%Nas"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195667", AppName: "115NAS助手", DisplayName: "115NAS助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195669", AppName: "sync vault", DisplayName: "sync vault"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195671", AppName: "个人自用备份", DisplayName: "个人自用备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195673", AppName: "lszs", DisplayName: "lszs"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195675", AppName: "个人云存储本地挂载", DisplayName: "个人云存储本地挂载"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195677", AppName: "个人博客", DisplayName: "个人博客"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195679", AppName: "test", DisplayName: "test"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195681", AppName: "播放器", DisplayName: "播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195683", AppName: "文件同步", DisplayName: "文件同步"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195685", AppName: "媒体库", DisplayName: "媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195687", AppName: "个人nas", DisplayName: "个人nas"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195689", AppName: "115云擎·开发者套件", DisplayName: "115云擎·开发者套件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195691", AppName: "幕云", DisplayName: "幕云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195693", AppName: "家用内网观影系统", DisplayName: "家用内网观影系统"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195695", AppName: "个人服务器", DisplayName: "个人服务器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195697", AppName: "link115", DisplayName: "link115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195699", AppName: "大地主的知识库", DisplayName: "大地主的知识库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195701", AppName: "雲联云端", DisplayName: "雲联云端"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195703", AppName: "Nas", DisplayName: "Nas"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195705", AppName: "Kyoma", DisplayName: "Kyoma"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195707", AppName: "自用播放器", DisplayName: "自用播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195709", AppName: "媒体库软件等", DisplayName: "媒体库软件等"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195711", AppName: "数据同步", DisplayName: "数据同步"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195713", AppName: "清风生酒舍-轻笔记", DisplayName: "清风生酒舍-轻笔记"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195715", AppName: "wlby", DisplayName: "wlby"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195717", AppName: "简中文档", DisplayName: "简中文档"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195719", AppName: "NAS云存储备份", DisplayName: "NAS云存储备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195721", AppName: "OneNas", DisplayName: "OneNas"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195723", AppName: "网盘接入", DisplayName: "网盘接入"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195727", AppName: "个人影视仓库", DisplayName: "个人影视仓库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195729", AppName: "LazyMedia", DisplayName: "LazyMedia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195731", AppName: "直播录制", DisplayName: "直播录制"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195733", AppName: "媒体库软件", DisplayName: "媒体库软件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195735", AppName: "MyMedia", DisplayName: "MyMedia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195737", AppName: "影视播放器类", DisplayName: "影视播放器类"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195739", AppName: "个人网盘服务", DisplayName: "个人网盘服务"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195741", AppName: "testapi", DisplayName: "testapi"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195743", AppName: "115个人下载管理", DisplayName: "115个人下载管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195745", AppName: "aitro", DisplayName: "aitro"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195747", AppName: "个人媒体播放器", DisplayName: "个人媒体播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195749", AppName: "个人资源库", DisplayName: "个人资源库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195751", AppName: "115生活", DisplayName: "115生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195753", AppName: "聚网维", DisplayName: "聚网维"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195755", AppName: "个人家庭自用", DisplayName: "个人家庭自用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195757", AppName: "CKT", DisplayName: "CKT"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195759", AppName: "1Space Drive", DisplayName: "1Space Drive"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195761", AppName: "小拾记", DisplayName: "小拾记"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195763", AppName: "金龙", DisplayName: "金龙"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195765", AppName: "满天繁星", DisplayName: "满天繁星"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195767", AppName: "记忆源点", DisplayName: "记忆源点"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195769", AppName: "文件直连工作站", DisplayName: "文件直连工作站"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195771", AppName: "HamHub", DisplayName: "HamHub"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195773", AppName: "家庭影视", DisplayName: "家庭影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195775", AppName: "N1nja", DisplayName: "N1nja"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195777", AppName: "madlax", DisplayName: "madlax"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195779", AppName: "视频播放", DisplayName: "视频播放"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195781", AppName: "小沨私人网盘", DisplayName: "小沨私人网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195783", AppName: "个人文件备份", DisplayName: "个人文件备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195785", AppName: "Bamhud", DisplayName: "Bamhud"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195787", AppName: "Stasher", DisplayName: "Stasher"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195789", AppName: "115查重助手", DisplayName: "115查重助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195791", AppName: "便携媒体库", DisplayName: "便携媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195793", AppName: "senplayer", DisplayName: "senplayer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195795", AppName: "zdir", DisplayName: "zdir"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195797", AppName: "图床", DisplayName: "图床"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195799", AppName: "若溪文学", DisplayName: "若溪文学"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195801", AppName: "self", DisplayName: "self"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195803", AppName: "同步备份及取用", DisplayName: "同步备份及取用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195805", AppName: "舆情数据分析", DisplayName: "舆情数据分析"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195807", AppName: "Synology NAS", DisplayName: "Synology NAS"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195809", AppName: "115client", DisplayName: "115client"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195811", AppName: "群晖相片备份", DisplayName: "群晖相片备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195813", AppName: "Alist", DisplayName: "Alist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195815", AppName: "家庭存储服务", DisplayName: "家庭存储服务"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195817", AppName: "爱我中华", DisplayName: "爱我中华"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195819", AppName: "open115", DisplayName: "open115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195821", AppName: "我的收藏夹", DisplayName: "我的收藏夹"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195823", AppName: "猫的橱窗", DisplayName: "猫的橱窗"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195825", AppName: "生活&115", DisplayName: "生活&115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195827", AppName: "Lplay", DisplayName: "Lplay"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195829", AppName: "本地挂载", DisplayName: "本地挂载"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195831", AppName: "nice115", DisplayName: "nice115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195833", AppName: "Auto Download", DisplayName: "Auto Download"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195835", AppName: "信息应用服务", DisplayName: "信息应用服务"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195837", AppName: "SsrDrive", DisplayName: "SsrDrive"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195839", AppName: "fast115", DisplayName: "fast115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195841", AppName: "个人家庭云存储", DisplayName: "个人家庭云存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195843", AppName: "视频文件备份", DisplayName: "视频文件备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195845", AppName: "115网盘webdav挂载", DisplayName: "115网盘webdav挂载"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195847", AppName: "家庭媒体库", DisplayName: "家庭媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195849", AppName: "图片生成和图片识别", DisplayName: "图片生成和图片识别"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195851", AppName: "115mtk", DisplayName: "115mtk"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195853", AppName: "webdav备份服务", DisplayName: "webdav备份服务"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195855", AppName: "自用云播放", DisplayName: "自用云播放"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195857", AppName: "小媒体", DisplayName: "小媒体"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195859", AppName: "pilishow", DisplayName: "pilishow"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195861", AppName: "网易媒体库软件网易爆米花", DisplayName: "网易媒体库软件网易爆米花"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195863", AppName: "个人视频", DisplayName: "个人视频"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195865", AppName: "PPNAS", DisplayName: "PPNAS"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195867", AppName: "LvShun", DisplayName: "LvShun"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195869", AppName: "115网盘OpenAPI对接服", DisplayName: "115网盘OpenAPI对接服"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195871", AppName: "影音库", DisplayName: "影音库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195873", AppName: "apple tv影视", DisplayName: "apple tv影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195875", AppName: "家庭影院", DisplayName: "家庭影院"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195877", AppName: "Player", DisplayName: "Player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195879", AppName: "115Palonbot", DisplayName: "115Palonbot"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195881", AppName: "I drive", DisplayName: "I drive"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195883", AppName: "自动更新插件", DisplayName: "自动更新插件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195885", AppName: "LYGCLOUD", DisplayName: "LYGCLOUD"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195887", AppName: "个人媒体库", DisplayName: "个人媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195889", AppName: "个人资料整理服务", DisplayName: "个人资料整理服务"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195891", AppName: "1媒体库", DisplayName: "1媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195893", AppName: "rclone_dev", DisplayName: "rclone_dev"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195895", AppName: "strm生成", DisplayName: "strm生成"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195897", AppName: "个人数据云管理探索", DisplayName: "个人数据云管理探索"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195899", AppName: "华之盘", DisplayName: "华之盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195901", AppName: "infuse", DisplayName: "infuse"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195903", AppName: "115影视资源", DisplayName: "115影视资源"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195905", AppName: "Afusekt", DisplayName: "Afusekt"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195907", AppName: "博客技术分享", DisplayName: "博客技术分享"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195909", AppName: "115文件管理", DisplayName: "115文件管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195911", AppName: "趣阁", DisplayName: "趣阁"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195913", AppName: "strmlinker", DisplayName: "strmlinker"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195915", AppName: "个人存储备份器", DisplayName: "个人存储备份器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195917", AppName: "MediaVault", DisplayName: "MediaVault"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195919", AppName: "115-nas-同步王", DisplayName: "115-nas-同步王"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195921", AppName: "rclone", DisplayName: "rclone"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195923", AppName: "CDaz", DisplayName: "CDaz"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195925", AppName: "tech-jiajia", DisplayName: "tech-jiajia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195927", AppName: "AudioAssistants", DisplayName: "AudioAssistants"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195929", AppName: "数据备份工具", DisplayName: "数据备份工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195931", AppName: "六个八", DisplayName: "六个八"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195933", AppName: "一五私人生活", DisplayName: "一五私人生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195935", AppName: "个人存储备份", DisplayName: "个人存储备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195937", AppName: "乔阒", DisplayName: "乔阒"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195939", AppName: "文件管理器", DisplayName: "文件管理器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195941", AppName: "趣芯", DisplayName: "趣芯"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195943", AppName: "Toxic-R", DisplayName: "Toxic-R"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195945", AppName: "吱凡", DisplayName: "吱凡"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195947", AppName: "源兴全咨询", DisplayName: "源兴全咨询"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195949", AppName: "STRM-云盘整合工具", DisplayName: "STRM-云盘整合工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195951", AppName: "MS", DisplayName: "MS"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195953", AppName: "猫头鹰文件", DisplayName: "猫头鹰文件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195955", AppName: "云盘同步备份", DisplayName: "云盘同步备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195957", AppName: "MediaSync", DisplayName: "MediaSync"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195959", AppName: "cc影音", DisplayName: "cc影音"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195961", AppName: "爱备份", DisplayName: "爱备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195963", AppName: "临商115存储", DisplayName: "临商115存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195965", AppName: "家家悦", DisplayName: "家家悦"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195967", AppName: "兔优科技", DisplayName: "兔优科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195969", AppName: "aaalist", DisplayName: "aaalist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195971", AppName: "京鲸", DisplayName: "京鲸"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195973", AppName: "Owin(吾影)", DisplayName: "Owin(吾影)"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195975", AppName: "阿山影视存储", DisplayName: "阿山影视存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195977", AppName: "115影视库", DisplayName: "115影视库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195979", AppName: "宅家洁", DisplayName: "宅家洁"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195981", AppName: "115生活APPapp", DisplayName: "115生活APPapp"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195983", AppName: "CloudPlayerX", DisplayName: "CloudPlayerX"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195985", AppName: "视频剪辑", DisplayName: "视频剪辑"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195987", AppName: "应视通", DisplayName: "应视通"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195989", AppName: "极光影视", DisplayName: "极光影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195991", AppName: "for Moyi", DisplayName: "for Moyi"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195993", AppName: "p115dav", DisplayName: "p115dav"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195995", AppName: "cms", DisplayName: "cms"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195997", AppName: "宜博通", DisplayName: "宜博通"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100195999", AppName: "积木科技", DisplayName: "积木科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196001", AppName: "115文件备份工具", DisplayName: "115文件备份工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196003", AppName: "建筑学习", DisplayName: "建筑学习"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196005", AppName: "ColdPlayer", DisplayName: "ColdPlayer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196007", AppName: "115Webdav", DisplayName: "115Webdav"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196009", AppName: "黑思app", DisplayName: "黑思app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196011", AppName: "115生活品牌APP", DisplayName: "115生活品牌APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196013", AppName: "CineFlow 云播引擎", DisplayName: "CineFlow 云播引擎"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196015", AppName: "strmcloud", DisplayName: "strmcloud"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196017", AppName: "个人视频播放器", DisplayName: "个人视频播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196019", AppName: "cloudnas", DisplayName: "cloudnas"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196021", AppName: "旅程ERP", DisplayName: "旅程ERP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196023", AppName: "软信智家", DisplayName: "软信智家"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196025", AppName: "云雀", DisplayName: "云雀"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196027", AppName: "medialinker", DisplayName: "medialinker"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196029", AppName: "个人自用媒体库", DisplayName: "个人自用媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196031", AppName: "淘梦云", DisplayName: "淘梦云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196033", AppName: "禹治视频AI", DisplayName: "禹治视频AI"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196035", AppName: "百宝箱app", DisplayName: "百宝箱app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196037", AppName: "云畔", DisplayName: "云畔"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196039", AppName: "家庭局域网视频播放", DisplayName: "家庭局域网视频播放"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196043", AppName: "MDCGO", DisplayName: "MDCGO"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196045", AppName: "天天网盘", DisplayName: "天天网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196047", AppName: "本地挂载APP", DisplayName: "本地挂载APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196049", AppName: "AfusektV", DisplayName: "AfusektV"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196051", AppName: "易出宝APP", DisplayName: "易出宝APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196053", AppName: "萌芽", DisplayName: "萌芽"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196055", AppName: "维克音乐", DisplayName: "维克音乐"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196057", AppName: "菜智通", DisplayName: "菜智通"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196059", AppName: "时科", DisplayName: "时科"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196061", AppName: "PotPlay", DisplayName: "PotPlay"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196063", AppName: "快影视", DisplayName: "快影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196065", AppName: "115影视备份专刊", DisplayName: "115影视备份专刊"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196067", AppName: "SpaceK", DisplayName: "SpaceK"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196069", AppName: "onemedia", DisplayName: "onemedia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196071", AppName: "飞翔壁纸", DisplayName: "飞翔壁纸"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196073", AppName: "p115", DisplayName: "p115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196075", AppName: "云间集", DisplayName: "云间集"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196077", AppName: "星图媒体库", DisplayName: "星图媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196079", AppName: "阳光小视", DisplayName: "阳光小视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196081", AppName: "青争影音库", DisplayName: "青争影音库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196083", AppName: "荧海", DisplayName: "荧海"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196085", AppName: "汇博招聘资料库", DisplayName: "汇博招聘资料库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196087", AppName: "Csust网盘", DisplayName: "Csust网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196089", AppName: "LuminArchive", DisplayName: "LuminArchive"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196091", AppName: "自用家庭影音", DisplayName: "自用家庭影音"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196093", AppName: "大军科技", DisplayName: "大军科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196095", AppName: "115King", DisplayName: "115King"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196097", AppName: "wechat_115", DisplayName: "wechat_115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196099", AppName: "DjiGo", DisplayName: "DjiGo"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196101", AppName: "115视频", DisplayName: "115视频"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196103", AppName: "KwokY", DisplayName: "KwokY"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196105", AppName: "waysdroid", DisplayName: "waysdroid"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196107", AppName: "ColaMedia", DisplayName: "ColaMedia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196109", AppName: "天干地支影视", DisplayName: "天干地支影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196111", AppName: "媒体管家", DisplayName: "媒体管家"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196113", AppName: "雪梨视听", DisplayName: "雪梨视听"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196115", AppName: "梵星ART", DisplayName: "梵星ART"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196117", AppName: "宇悦科技", DisplayName: "宇悦科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196119", AppName: "筑绘通", DisplayName: "筑绘通"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196121", AppName: "富芸购", DisplayName: "富芸购"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196123", AppName: "消防维保助手", DisplayName: "消防维保助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196125", AppName: "CloudBox", DisplayName: "CloudBox"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196127", AppName: "花名影视", DisplayName: "花名影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196129", AppName: "浩睿影视", DisplayName: "浩睿影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196131", AppName: "创信云", DisplayName: "创信云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196133", AppName: "Symedia", DisplayName: "Symedia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196135", AppName: "cg115", DisplayName: "cg115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196137", AppName: "TopsecHZ", DisplayName: "TopsecHZ"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196139", AppName: "产品名称", DisplayName: "产品名称"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196141", AppName: "banner", DisplayName: "banner"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196143", AppName: "VIDHUB-个人", DisplayName: "VIDHUB-个人"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196145", AppName: "绮丽相册", DisplayName: "绮丽相册"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196147", AppName: "随手拍", DisplayName: "随手拍"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196149", AppName: "柒柒知道", DisplayName: "柒柒知道"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196151", AppName: "Bunny - 真高清视频播放", DisplayName: "Bunny - 真高清视频播放"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196153", AppName: "影音互联播放器", DisplayName: "影音互联播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196155", AppName: "Watching movies", DisplayName: "Watching movies"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196157", AppName: "摇裤", DisplayName: "摇裤"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196159", AppName: "汇博招聘", DisplayName: "汇博招聘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196161", AppName: "家庭影音", DisplayName: "家庭影音"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196163", AppName: "LIN", DisplayName: "LIN"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196165", AppName: "玖贰捌多媒体", DisplayName: "玖贰捌多媒体"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196167", AppName: "麻雀播放器app", DisplayName: "麻雀播放器app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196169", AppName: "奇妙播放器app", DisplayName: "奇妙播放器app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196171", AppName: "影视厅", DisplayName: "影视厅"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196173", AppName: "点滴时刻", DisplayName: "点滴时刻"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196175", AppName: "花木棉影音", DisplayName: "花木棉影音"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196177", AppName: "115文件编辑", DisplayName: "115文件编辑"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196179", AppName: "115视频树", DisplayName: "115视频树"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196181", AppName: "忘归期", DisplayName: "忘归期"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196183", AppName: "yxkjapp", DisplayName: "yxkjapp"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196185", AppName: "115-llgk", DisplayName: "115-llgk"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196187", AppName: "行风应用", DisplayName: "行风应用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196189", AppName: "仰望坡影视", DisplayName: "仰望坡影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196191", AppName: "回滚助手", DisplayName: "回滚助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196193", AppName: "Lipma", DisplayName: "Lipma"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196195", AppName: "智影云", DisplayName: "智影云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196197", AppName: "激光眼app", DisplayName: "激光眼app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196199", AppName: "T小盘", DisplayName: "T小盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196201", AppName: "MeiDa", DisplayName: "MeiDa"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196203", AppName: "AutoStrm", DisplayName: "AutoStrm"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196205", AppName: "Cloud X player", DisplayName: "Cloud X player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196207", AppName: "虾仁批发", DisplayName: "虾仁批发"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196209", AppName: "星良视频app", DisplayName: "星良视频app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196211", AppName: "LB文件", DisplayName: "LB文件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196213", AppName: "窃老头网络生活", DisplayName: "窃老头网络生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196215", AppName: "FIrLife", DisplayName: "FIrLife"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196217", AppName: "115生活appzzqiuzh", DisplayName: "115生活appzzqiuzh"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196219", AppName: "gg播放器", DisplayName: "gg播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196221", AppName: "花棠", DisplayName: "花棠"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196223", AppName: "陆捌叁", DisplayName: "陆捌叁"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196225", AppName: "MediaLinker", DisplayName: "MediaLinker"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196227", AppName: "管理媒体库", DisplayName: "管理媒体库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196229", AppName: "新世界", DisplayName: "新世界"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196231", AppName: "个人媒体库管理", DisplayName: "个人媒体库管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196233", AppName: "115拼好盘", DisplayName: "115拼好盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196235", AppName: "幸运播放器", DisplayName: "幸运播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196237", AppName: "Sam", DisplayName: "Sam"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196239", AppName: "春光九汇", DisplayName: "春光九汇"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196241", AppName: "曙光云", DisplayName: "曙光云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196243", AppName: "MediaGO", DisplayName: "MediaGO"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196245", AppName: "UE", DisplayName: "UE"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196247", AppName: "优视播放", DisplayName: "优视播放"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196249", AppName: "噼里啪啦影视", DisplayName: "噼里啪啦影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196251", AppName: "115多文件夹文件管理", DisplayName: "115多文件夹文件管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196253", AppName: "中豪", DisplayName: "中豪"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196255", AppName: "115全能助手", DisplayName: "115全能助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196257", AppName: "MoviePilot", DisplayName: "MoviePilot"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196259", AppName: "自用", DisplayName: "自用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196261", AppName: "Emby-toolkit", DisplayName: "Emby-toolkit"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196263", AppName: "马春杰杰博客", DisplayName: "马春杰杰博客"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196265", AppName: "Stermlink", DisplayName: "Stermlink"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196267", AppName: "大强", DisplayName: "大强"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196269", AppName: "zzfun", DisplayName: "zzfun"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196271", AppName: "唯品家", DisplayName: "唯品家"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196273", AppName: "ZFile", DisplayName: "ZFile"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196275", AppName: "Jax播放器", DisplayName: "Jax播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196277", AppName: "MuchanTV", DisplayName: "MuchanTV"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196279", AppName: "115观看", DisplayName: "115观看"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196281", AppName: "玖久视频", DisplayName: "玖久视频"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196283", AppName: "KIKU影视", DisplayName: "KIKU影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196285", AppName: "影音大王", DisplayName: "影音大王"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196287", AppName: "115_strm刮削助手", DisplayName: "115_strm刮削助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196289", AppName: "恒科达", DisplayName: "恒科达"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196291", AppName: "时光备份", DisplayName: "时光备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196293", AppName: "千禾教育", DisplayName: "千禾教育"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196295", AppName: "松邦精密", DisplayName: "松邦精密"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196297", AppName: "大鸟转转", DisplayName: "大鸟转转"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196299", AppName: "dylany", DisplayName: "dylany"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196301", AppName: "智媒Pro", DisplayName: "智媒Pro"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196303", AppName: "科技生活", DisplayName: "科技生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196305", AppName: "colderseason", DisplayName: "colderseason"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196307", AppName: "东鹏", DisplayName: "东鹏"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196309", AppName: "211", DisplayName: "211"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196311", AppName: "云存宝", DisplayName: "云存宝"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196313", AppName: "云助手", DisplayName: "云助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196315", AppName: "Eliva", DisplayName: "Eliva"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196317", AppName: "阿神的幻想", DisplayName: "阿神的幻想"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196319", AppName: "柳沙教育", DisplayName: "柳沙教育"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196321", AppName: "斗金科技", DisplayName: "斗金科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196323", AppName: "林业信息", DisplayName: "林业信息"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196325", AppName: "融胜科技", DisplayName: "融胜科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196327", AppName: "Mbox", DisplayName: "Mbox"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196329", AppName: "115助手", DisplayName: "115助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196331", AppName: "万笑庄", DisplayName: "万笑庄"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196333", AppName: "古恩", DisplayName: "古恩"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196335", AppName: "115文件分享", DisplayName: "115文件分享"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196337", AppName: "老林", DisplayName: "老林"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196339", AppName: "美丽世界", DisplayName: "美丽世界"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196341", AppName: "asd", DisplayName: "asd"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196343", AppName: "暗组Prism", DisplayName: "暗组Prism"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196345", AppName: "115pan", DisplayName: "115pan"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196347", AppName: "dc播放器", DisplayName: "dc播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196349", AppName: "寒潭", DisplayName: "寒潭"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196351", AppName: "115爆米花", DisplayName: "115爆米花"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196353", AppName: "Betterman", DisplayName: "Betterman"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196355", AppName: "云企NAS", DisplayName: "云企NAS"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196357", AppName: "115云存储助手", DisplayName: "115云存储助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196359", AppName: "银河app", DisplayName: "银河app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196361", AppName: "尚里", DisplayName: "尚里"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196363", AppName: "Alplayer", DisplayName: "Alplayer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196365", AppName: "安迪", DisplayName: "安迪"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196367", AppName: "forewing", DisplayName: "forewing"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196369", AppName: "出海云平台", DisplayName: "出海云平台"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196371", AppName: "个人网盘数据整理", DisplayName: "个人网盘数据整理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196373", AppName: "Hsly", DisplayName: "Hsly"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196375", AppName: "巨浪自媒体", DisplayName: "巨浪自媒体"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196377", AppName: "无尽的雷鸣", DisplayName: "无尽的雷鸣"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196379", AppName: "云影", DisplayName: "云影"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196381", AppName: "zzz", DisplayName: "zzz"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196383", AppName: "AlanZhai", DisplayName: "AlanZhai"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196385", AppName: "海螺音乐app", DisplayName: "海螺音乐app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196387", AppName: "易存", DisplayName: "易存"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196389", AppName: "爱学慧思", DisplayName: "爱学慧思"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196391", AppName: "电子杂货铺", DisplayName: "电子杂货铺"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196393", AppName: "故事杂货铺", DisplayName: "故事杂货铺"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196395", AppName: "onestrm", DisplayName: "onestrm"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196397", AppName: "文档在线预览", DisplayName: "文档在线预览"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196399", AppName: "115生活APP007", DisplayName: "115生活APP007"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196401", AppName: "115网络生活服务", DisplayName: "115网络生活服务"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196403", AppName: "个人网盘文件同步", DisplayName: "个人网盘文件同步"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196405", AppName: "heroes", DisplayName: "heroes"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196407", AppName: "玥明数据", DisplayName: "玥明数据"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196409", AppName: "flowdock", DisplayName: "flowdock"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196411", AppName: "闪秒云", DisplayName: "闪秒云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196413", AppName: "牛斗龙光", DisplayName: "牛斗龙光"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196415", AppName: "115211", DisplayName: "115211"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196417", AppName: "KaiSeR", DisplayName: "KaiSeR"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196419", AppName: "何恺", DisplayName: "何恺"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196421", AppName: "微生活网络科技", DisplayName: "微生活网络科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196423", AppName: "xiaoguo115", DisplayName: "xiaoguo115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196425", AppName: "个数备份", DisplayName: "个数备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196427", AppName: "115个人观影生活", DisplayName: "115个人观影生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196429", AppName: "ModelBus APP", DisplayName: "ModelBus APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196431", AppName: "猫熊Player", DisplayName: "猫熊Player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196433", AppName: "115自动磁力", DisplayName: "115自动磁力"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196435", AppName: "115存储宝", DisplayName: "115存储宝"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196437", AppName: "理解为大", DisplayName: "理解为大"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196439", AppName: "亭好伊", DisplayName: "亭好伊"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196441", AppName: "Mzone", DisplayName: "Mzone"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196443", AppName: "115小生活", DisplayName: "115小生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196445", AppName: "蓝光原盘电影API", DisplayName: "蓝光原盘电影API"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196447", AppName: "幻想乡APP", DisplayName: "幻想乡APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196449", AppName: "林涧小栈", DisplayName: "林涧小栈"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196451", AppName: "Kujira-app", DisplayName: "Kujira-app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196453", AppName: "影云库", DisplayName: "影云库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196455", AppName: "理想APP", DisplayName: "理想APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196457", AppName: "影视仓", DisplayName: "影视仓"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196459", AppName: "115影视资料存档", DisplayName: "115影视资料存档"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196461", AppName: "麦点备份", DisplayName: "麦点备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196463", AppName: "恒天益", DisplayName: "恒天益"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196465", AppName: "哨盾", DisplayName: "哨盾"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196467", AppName: "Emiya自走人形", DisplayName: "Emiya自走人形"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196469", AppName: "传扬文化", DisplayName: "传扬文化"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196471", AppName: "灿烂生活", DisplayName: "灿烂生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196473", AppName: "辰兴网络", DisplayName: "辰兴网络"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196475", AppName: "品质生活APP", DisplayName: "品质生活APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196477", AppName: "EasyPlayer", DisplayName: "EasyPlayer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196479", AppName: "云视听家庭流媒体", DisplayName: "云视听家庭流媒体"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196481", AppName: "AI存储", DisplayName: "AI存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196483", AppName: "内部文件管理", DisplayName: "内部文件管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196485", AppName: "zFuse", DisplayName: "zFuse"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196487", AppName: "雨辰搜索", DisplayName: "雨辰搜索"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196489", AppName: "个人手机电视等平台观影", DisplayName: "个人手机电视等平台观影"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196491", AppName: "青空未来", DisplayName: "青空未来"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196493", AppName: "家庭影音先锋", DisplayName: "家庭影音先锋"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196495", AppName: "云朵影视", DisplayName: "云朵影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196497", AppName: "115相册助手", DisplayName: "115相册助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196499", AppName: "Nasdrive", DisplayName: "Nasdrive"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196501", AppName: "战神部落app", DisplayName: "战神部落app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196503", AppName: "Cloudnas drive", DisplayName: "Cloudnas drive"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196505", AppName: "MBox", DisplayName: "MBox"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196507", AppName: "lcloud", DisplayName: "lcloud"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196509", AppName: "115生活ASPP", DisplayName: "115生活ASPP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196511", AppName: "网盘助手", DisplayName: "网盘助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196513", AppName: "知识猎人", DisplayName: "知识猎人"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196515", AppName: "小小湾意", DisplayName: "小小湾意"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196517", AppName: "大鱼影像", DisplayName: "大鱼影像"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196519", AppName: "当老师APP", DisplayName: "当老师APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196521", AppName: "悠悠美业", DisplayName: "悠悠美业"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196523", AppName: "flyme", DisplayName: "flyme"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196525", AppName: "看视频app", DisplayName: "看视频app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196527", AppName: "协同文档助手", DisplayName: "协同文档助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196529", AppName: "人生风云", DisplayName: "人生风云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196531", AppName: "115生活app777", DisplayName: "115生活app777"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196533", AppName: "森林巴士", DisplayName: "森林巴士"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196535", AppName: "水电", DisplayName: "水电"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196537", AppName: "个人", DisplayName: "个人"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196539", AppName: "魔影", DisplayName: "魔影"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196541", AppName: "鲑鲑科技", DisplayName: "鲑鲑科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196543", AppName: "电计贸易", DisplayName: "电计贸易"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196545", AppName: "旬光影视", DisplayName: "旬光影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196547", AppName: "mac文件备份", DisplayName: "mac文件备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196549", AppName: "富裕生活", DisplayName: "富裕生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196551", AppName: "AGC Player", DisplayName: "AGC Player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196553", AppName: "VidGenerate", DisplayName: "VidGenerate"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196555", AppName: "巨硬云管理APP", DisplayName: "巨硬云管理APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196557", AppName: "秦磊", DisplayName: "秦磊"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196559", AppName: "痛尘", DisplayName: "痛尘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196561", AppName: "个人开发者", DisplayName: "个人开发者"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196563", AppName: "Gimcy", DisplayName: "Gimcy"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196565", AppName: "哆啦网络", DisplayName: "哆啦网络"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196567", AppName: "cine_x", DisplayName: "cine_x"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196571", AppName: "zwbcloud", DisplayName: "zwbcloud"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196573", AppName: "易盘", DisplayName: "易盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196575", AppName: "阳光碎裂", DisplayName: "阳光碎裂"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196577", AppName: "天穹", DisplayName: "天穹"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196579", AppName: "肥羊APP", DisplayName: "肥羊APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196581", AppName: "haha", DisplayName: "haha"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196583", AppName: "内部api", DisplayName: "内部api"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196585", AppName: "igloo", DisplayName: "igloo"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196587", AppName: "架构api", DisplayName: "架构api"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196589", AppName: "一点播", DisplayName: "一点播"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196591", AppName: "CKT云", DisplayName: "CKT云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196593", AppName: "保益悦听", DisplayName: "保益悦听"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196595", AppName: "Daiwa BC电商自动化程序", DisplayName: "Daiwa BC电商自动化程序"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196597", AppName: "LOVE TOWN", DisplayName: "LOVE TOWN"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196599", AppName: "CMS-光影穿梭机空间", DisplayName: "CMS-光影穿梭机空间"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196601", AppName: "115App", DisplayName: "115App"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196603", AppName: "粒辛新媒体设计工作室", DisplayName: "粒辛新媒体设计工作室"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196605", AppName: "嗳喔哇", DisplayName: "嗳喔哇"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196607", AppName: "识真文库", DisplayName: "识真文库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196609", AppName: "奇异城", DisplayName: "奇异城"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196611", AppName: "解压大师APP", DisplayName: "解压大师APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196613", AppName: "VR影视", DisplayName: "VR影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196615", AppName: "山丘阅读APP", DisplayName: "山丘阅读APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196617", AppName: "极客库", DisplayName: "极客库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196619", AppName: "刺猬", DisplayName: "刺猬"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196621", AppName: "惠课", DisplayName: "惠课"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196623", AppName: "Time Space", DisplayName: "Time Space"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196625", AppName: "易安科技", DisplayName: "易安科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196627", AppName: "小杜能力", DisplayName: "小杜能力"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196629", AppName: "哈夫克", DisplayName: "哈夫克"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196631", AppName: "大江app", DisplayName: "大江app"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196633", AppName: "GULTV", DisplayName: "GULTV"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196635", AppName: "115生活APP", DisplayName: "115生活APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196637", AppName: "plex播放器", DisplayName: "plex播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196639", AppName: "jqsyzqt影视科技", DisplayName: "jqsyzqt影视科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196641", AppName: "E视界", DisplayName: "E视界"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196643", AppName: "蓝潮Player", DisplayName: "蓝潮Player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196645", AppName: "氆氇", DisplayName: "氆氇"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196647", AppName: "衣湖", DisplayName: "衣湖"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196649", AppName: "云备份APP", DisplayName: "云备份APP"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196653", AppName: "AirExplorer", DisplayName: "AirExplorer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196655", AppName: "御风网络工作室", DisplayName: "御风网络工作室"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196657", AppName: "云盘管家pro", DisplayName: "云盘管家pro"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196659", AppName: "网盘管理系统", DisplayName: "网盘管理系统"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196661", AppName: "FastManga", DisplayName: "FastManga"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196663", AppName: "小康小诺", DisplayName: "小康小诺"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196665", AppName: "影视通", DisplayName: "影视通"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196667", AppName: "LMP Music", DisplayName: "LMP Music"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196669", AppName: "无9527", DisplayName: "无9527"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196671", AppName: "媒体云", DisplayName: "媒体云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196673", AppName: "EverPlay音乐播放器", DisplayName: "EverPlay音乐播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196675", AppName: "北鸢", DisplayName: "北鸢"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196677", AppName: "安泰恒", DisplayName: "安泰恒"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196679", AppName: "Light Player", DisplayName: "Light Player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196681", AppName: "芝杜6645213", DisplayName: "芝杜6645213"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196683", AppName: "启辰生活", DisplayName: "启辰生活"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196685", AppName: "学习教育", DisplayName: "学习教育"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196687", AppName: "安萧", DisplayName: "安萧"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196689", AppName: "个人开发者胡先生", DisplayName: "个人开发者胡先生"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196691", AppName: "巧用科技", DisplayName: "巧用科技"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196693", AppName: "115-gold", DisplayName: "115-gold"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196695", AppName: "Nexaura", DisplayName: "Nexaura"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196697", AppName: "长沙艾斯云商贸行", DisplayName: "长沙艾斯云商贸行"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196699", AppName: "摩登原始人单机版", DisplayName: "摩登原始人单机版"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196701", AppName: "拟声", DisplayName: "拟声"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196703", AppName: "飞敏", DisplayName: "飞敏"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196705", AppName: "榴金岁月", DisplayName: "榴金岁月"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196707", AppName: "MyFile", DisplayName: "MyFile"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196709", AppName: "正恒网络", DisplayName: "正恒网络"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196711", AppName: "石榴", DisplayName: "石榴"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196713", AppName: "花生的115", DisplayName: "花生的115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196771", AppName: "网易云音乐-测试", DisplayName: "网易云音乐-测试"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196775", AppName: "MS-GO", DisplayName: "MS-GO"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196781", AppName: "个人体验", DisplayName: "个人体验"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196789", AppName: "115UploadTool", DisplayName: "115UploadTool"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196799", AppName: "软件同步助手", DisplayName: "软件同步助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196815", AppName: "okstrm", DisplayName: "okstrm"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196819", AppName: "pc2link", DisplayName: "pc2link"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196823", AppName: "jeans", DisplayName: "jeans"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196825", AppName: "115+", DisplayName: "115+"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196827", AppName: "SyncMe", DisplayName: "SyncMe"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196831", AppName: "陌小逸", DisplayName: "陌小逸"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196835", AppName: "key", DisplayName: "key"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196841", AppName: "小迈Board", DisplayName: "小迈Board"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196843", AppName: "Media Saber", DisplayName: "Media Saber"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196857", AppName: "自用alist", DisplayName: "自用alist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196859", AppName: "webdav", DisplayName: "webdav"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196861", AppName: "OpenList-0o0", DisplayName: "OpenList-0o0"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196863", AppName: "测试", DisplayName: "测试"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196867", AppName: "酷我派", DisplayName: "酷我派"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196873", AppName: "blist", DisplayName: "blist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196875", AppName: "olist", DisplayName: "olist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196885", AppName: "Redit", DisplayName: "Redit"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196901", AppName: "115Download-TV", DisplayName: "115Download-TV"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196913", AppName: "webview", DisplayName: "webview"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196915", AppName: "智能网盘助手", DisplayName: "智能网盘助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196925", AppName: "my115", DisplayName: "my115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196929", AppName: "GO", DisplayName: "GO"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196937", AppName: "lhp home", DisplayName: "lhp home"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196945", AppName: "什么值得听", DisplayName: "什么值得听"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196947", AppName: "115cms oh", DisplayName: "115cms oh"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196951", AppName: "DFGH", DisplayName: "DFGH"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196957", AppName: "Cinemore", DisplayName: "Cinemore"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196963", AppName: "115open", DisplayName: "115open"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196977", AppName: "One能Box", DisplayName: "One能Box"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196981", AppName: "Moci", DisplayName: "Moci"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196983", AppName: "VRenamer", DisplayName: "VRenamer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196993", AppName: "115网盘改名", DisplayName: "115网盘改名"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196995", AppName: "CDancer 云盘助手", DisplayName: "CDancer 云盘助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100196997", AppName: "plex-strm-nginx", DisplayName: "plex-strm-nginx"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197005", AppName: "CloudSync", DisplayName: "CloudSync"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197009", AppName: "Media Pro", DisplayName: "Media Pro"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197011", AppName: "网易云音乐", DisplayName: "网易云音乐"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197013", AppName: "CokEmby", DisplayName: "CokEmby"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197021", AppName: "终极备份", DisplayName: "终极备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197025", AppName: "BackupTool", DisplayName: "BackupTool"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197029", AppName: "云间拾遗", DisplayName: "云间拾遗"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197033", AppName: "MSGO", DisplayName: "MSGO"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197037", AppName: "月光放映机", DisplayName: "月光放映机"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197045", AppName: "studyApp", DisplayName: "studyApp"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197051", AppName: "小马好车", DisplayName: "小马好车"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197071", AppName: "fluxa-cloud", DisplayName: "fluxa-cloud"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197089", AppName: "蕾姆player", DisplayName: "蕾姆player"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197091", AppName: "神秘播放器", DisplayName: "神秘播放器", Deprecated: true}, // 40140108 应用未审核通过 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197099", AppName: "emby_cms", DisplayName: "emby_cms"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197101", AppName: "CloudMediaSynC", DisplayName: "CloudMediaSynC"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197103", AppName: "Yy", DisplayName: "Yy"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197115", AppName: "pikaos", DisplayName: "pikaos"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197119", AppName: "cloud manager", DisplayName: "cloud manager"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197129", AppName: "流媒体视频播放测试", DisplayName: "流媒体视频播放测试"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197139", AppName: "卡噗噗噗", DisplayName: "卡噗噗噗"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197141", AppName: "静听", DisplayName: "静听"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197169", AppName: "voltmonkey", DisplayName: "voltmonkey"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197183", AppName: "MovieStrm", DisplayName: "MovieStrm"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197197", AppName: "Koodo Reader", DisplayName: "Koodo Reader"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197215", AppName: "蒙太奇测试版", DisplayName: "蒙太奇测试版"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197217", AppName: "YCLabStorage", DisplayName: "YCLabStorage"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197225", AppName: "飞翔中文网", DisplayName: "飞翔中文网"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197229", AppName: "openlist1322", DisplayName: "openlist1322"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197237", AppName: "夜翼阅读器", DisplayName: "夜翼阅读器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197241", AppName: "TopoWorld", DisplayName: "TopoWorld"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197243", AppName: "MediaLibrary", DisplayName: "MediaLibrary", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197245", AppName: "115&Wen", DisplayName: "115&Wen"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197255", AppName: "115全能助手_", DisplayName: "115全能助手_"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197281", AppName: "我的英语本子", DisplayName: "我的英语本子", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197285", AppName: "mpv_test", DisplayName: "mpv_test"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197287", AppName: "Cinetry", DisplayName: "Cinetry"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197289", AppName: "dlhelper", DisplayName: "dlhelper"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197293", AppName: "Open-List", DisplayName: "Open-List"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197297", AppName: "bottttg", DisplayName: "bottttg"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197303", AppName: "OpenList", DisplayName: "OpenList"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197305", AppName: "emby-plus", DisplayName: "emby-plus", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197307", AppName: "ikaros-dev", DisplayName: "ikaros-dev", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197309", AppName: "ikaros", DisplayName: "ikaros", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197317", AppName: "fadsfdasf", DisplayName: "fadsfdasf", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197321", AppName: "EasyFileManager", DisplayName: "EasyFileManager"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197323", AppName: "Cloud Media Syn", DisplayName: "Cloud Media Syn", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197327", AppName: "自用NAS", DisplayName: "自用NAS", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197329", AppName: "个人自用家庭影院", DisplayName: "个人自用家庭影院"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197331", AppName: "onelist", DisplayName: "onelist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197333", AppName: "七月测试011", DisplayName: "七月测试011", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197335", AppName: "Zdir", DisplayName: "Zdir"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197341", AppName: "摄影管理", DisplayName: "摄影管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197343", AppName: "HomeNetDisk", DisplayName: "HomeNetDisk", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197345", AppName: "openlist-cap", DisplayName: "openlist-cap"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197347", AppName: "NextcloudDAV", DisplayName: "NextcloudDAV", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197349", AppName: "CBack", DisplayName: "CBack", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197351", AppName: "zptcms", DisplayName: "zptcms", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197353", AppName: "G theater", DisplayName: "G theater", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197355", AppName: "w.openlist", DisplayName: "w.openlist", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197357", AppName: "myapp", DisplayName: "myapp", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197359", AppName: "ding", DisplayName: "ding", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197361", AppName: "test333", DisplayName: "test333", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197363", AppName: "小柠檬CMS", DisplayName: "小柠檬CMS"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197365", AppName: "NAS", DisplayName: "NAS"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197367", AppName: "DevToolKit Pro", DisplayName: "DevToolKit Pro"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197369", AppName: "Aico Openlist", DisplayName: "Aico Openlist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197371", AppName: "蜂巢", DisplayName: "蜂巢", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197373", AppName: "openlist-ek", DisplayName: "openlist-ek", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197375", AppName: "My115Files", DisplayName: "My115Files", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197377", AppName: "medor", DisplayName: "medor"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197379", AppName: "Bin", DisplayName: "Bin"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197381", AppName: "OpenList_115", DisplayName: "OpenList_115", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197385", AppName: "OpenList2", DisplayName: "OpenList2", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197387", AppName: "openListLocal", DisplayName: "openListLocal", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197389", AppName: "opllist", DisplayName: "opllist", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197391", AppName: "文档管理", DisplayName: "文档管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197393", AppName: "115list", DisplayName: "115list", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197395", AppName: "openlisttest123", DisplayName: "openlisttest123"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197397", AppName: "zmnmax", DisplayName: "zmnmax", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197399", AppName: "Rclone-115", DisplayName: "Rclone-115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197401", AppName: "智能元数据管理", DisplayName: "智能元数据管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197405", AppName: "tina-openlist", DisplayName: "tina-openlist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197407", AppName: "mylist", DisplayName: "mylist", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197409", AppName: "cloudmediesync", DisplayName: "cloudmediesync", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197411", AppName: "影音使用", DisplayName: "影音使用", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197415", AppName: "AsongFastSave", DisplayName: "AsongFastSave"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197417", AppName: "115player", DisplayName: "115player", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197419", AppName: "115to123", DisplayName: "115to123", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197421", AppName: "SongmuSave", DisplayName: "SongmuSave", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197423", AppName: "六鑫", DisplayName: "六鑫"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197425", AppName: "my_openlist", DisplayName: "my_openlist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197427", AppName: "cloudplayer", DisplayName: "cloudplayer", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197429", AppName: "亲子相册", DisplayName: "亲子相册", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197433", AppName: "备份盘", DisplayName: "备份盘", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197435", AppName: "openlist-115", DisplayName: "openlist-115", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197437", AppName: "媒体中心", DisplayName: "媒体中心"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197439", AppName: "cloudplayer2", DisplayName: "cloudplayer2", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197441", AppName: "doc仓库", DisplayName: "doc仓库"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197443", AppName: "知道乎", DisplayName: "知道乎", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197445", AppName: "Alist-HKYZF", DisplayName: "Alist-HKYZF", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197447", AppName: "禹云存储", DisplayName: "禹云存储", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197449", AppName: "果友派", DisplayName: "果友派"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197451", AppName: "小鸡啄米文件管理", DisplayName: "小鸡啄米文件管理", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197453", AppName: "影视strm", DisplayName: "影视strm"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197455", AppName: "影视-strm", DisplayName: "影视-strm", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197457", AppName: "牧云webos", DisplayName: "牧云webos", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197461", AppName: "oplist", DisplayName: "oplist", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197463", AppName: "我的115", DisplayName: "我的115", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197467", AppName: "nas同步", DisplayName: "nas同步", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197469", AppName: "scp云盘端口", DisplayName: "scp云盘端口"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197471", AppName: "内容使用", DisplayName: "内容使用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197473", AppName: "cayin", DisplayName: "cayin"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197475", AppName: "个人云盘工具", DisplayName: "个人云盘工具", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197477", AppName: "拾光坞", DisplayName: "拾光坞"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197479", AppName: "星小易加速器", DisplayName: "星小易加速器", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197481", AppName: "星小易客户端", DisplayName: "星小易客户端", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197483", AppName: "wjj测试用APP", DisplayName: "wjj测试用APP", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197485", AppName: "OpenListAPI", DisplayName: "OpenListAPI"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197487", AppName: "Diggoo - oplist", DisplayName: "Diggoo - oplist", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197491", AppName: "优途", DisplayName: "优途", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197493", AppName: "audiotools", DisplayName: "audiotools"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197495", AppName: "cedar-api", DisplayName: "cedar-api"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197497", AppName: "迅雷影音", DisplayName: "迅雷影音"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197501", AppName: "娲众云库", DisplayName: "娲众云库", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197503", AppName: "MQ的媒体库", DisplayName: "MQ的媒体库", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197507", AppName: "Vidora", DisplayName: "Vidora"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197509", AppName: "我的接口", DisplayName: "我的接口", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197511", AppName: "我的Openlist", DisplayName: "我的Openlist", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197513", AppName: "文件查看", DisplayName: "文件查看", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197517", AppName: "熊猫助手", DisplayName: "熊猫助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197519", AppName: "海异云", DisplayName: "海异云"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197521", AppName: "mlmll", DisplayName: "mlmll", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197523", AppName: "115闪推", DisplayName: "115闪推"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197525", AppName: "skybowen", DisplayName: "skybowen"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197527", AppName: "冰块阅读器", DisplayName: "冰块阅读器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197529", AppName: "HKBC", DisplayName: "HKBC", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197531", AppName: "小水滴", DisplayName: "小水滴"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197533", AppName: "synology", DisplayName: "synology"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197535", AppName: "oplist播放", DisplayName: "oplist播放", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197537", AppName: "lds测试", DisplayName: "lds测试", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197539", AppName: "激扬工具", DisplayName: "激扬工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197541", AppName: "文件整理", DisplayName: "文件整理", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197543", AppName: "openlistshimoxi", DisplayName: "openlistshimoxi", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197545", AppName: "bound", DisplayName: "bound"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197547", AppName: "openlist-1", DisplayName: "openlist-1", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197549", AppName: "huamei", DisplayName: "huamei", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197551", AppName: "homedisk", DisplayName: "homedisk"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197553", AppName: "mp", DisplayName: "mp", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197555", AppName: "Openlist170", DisplayName: "Openlist170", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197557", AppName: "kingsteve.001", DisplayName: "kingsteve.001"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197559", AppName: "tgbot-115", DisplayName: "tgbot-115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197563", AppName: "4XVR视频播放器", DisplayName: "4XVR视频播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197565", AppName: "kingsteve.002", DisplayName: "kingsteve.002", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197567", AppName: "openlist920", DisplayName: "openlist920", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197569", AppName: "115_Organizatio", DisplayName: "115_Organizatio"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197571", AppName: "115视界引擎", DisplayName: "115视界引擎", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197573", AppName: "数据备份", DisplayName: "数据备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197577", AppName: "LitePan", DisplayName: "LitePan"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197581", AppName: "wlms", DisplayName: "wlms", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197583", AppName: "Mac俱乐部", DisplayName: "Mac俱乐部"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197587", AppName: "我的观影小屋", DisplayName: "我的观影小屋"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197589", AppName: "爱唯逸云存储", DisplayName: "爱唯逸云存储"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197591", AppName: "Anthony", DisplayName: "Anthony"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197593", AppName: "rabbit", DisplayName: "rabbit"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197597", AppName: "Edwards", DisplayName: "Edwards"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197599", AppName: "挂载助手", DisplayName: "挂载助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197601", AppName: "zsxldy", DisplayName: "zsxldy"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197603", AppName: "LikeTV", DisplayName: "LikeTV", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197609", AppName: "cktyun", DisplayName: "cktyun", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197611", AppName: "115文件整理", DisplayName: "115文件整理", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197613", AppName: "oma", DisplayName: "oma", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197615", AppName: "115刮削移动工具", DisplayName: "115刮削移动工具", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197617", AppName: "小幻影视", DisplayName: "小幻影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197619", AppName: "六爻卦摊", DisplayName: "六爻卦摊", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197621", AppName: "火火火网盘", DisplayName: "火火火网盘", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197625", AppName: "流舟文件", DisplayName: "流舟文件"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197627", AppName: "openlist-akalin", DisplayName: "openlist-akalin"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197629", AppName: "kabishunmsgo", DisplayName: "kabishunmsgo", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197631", AppName: "爽快连接", DisplayName: "爽快连接"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197633", AppName: "diskfuse", DisplayName: "diskfuse"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197635", AppName: "OpenList.Mondo", DisplayName: "OpenList.Mondo", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197637", AppName: "Easy VR Player", DisplayName: "Easy VR Player", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197641", AppName: "embynginx助手", DisplayName: "embynginx助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197645", AppName: "cms-husbmv", DisplayName: "cms-husbmv", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197647", AppName: "Vide Liarary", DisplayName: "Vide Liarary", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197649", AppName: "PanTools", DisplayName: "PanTools"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197651", AppName: "SmartStrm", DisplayName: "SmartStrm"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197653", AppName: "云下载添加", DisplayName: "云下载添加", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197657", AppName: "神经元", DisplayName: "神经元"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197659", AppName: "filelist", DisplayName: "filelist", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197661", AppName: "个人webdav", DisplayName: "个人webdav", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197665", AppName: "Q115-STRM", DisplayName: "Q115-STRM", Deprecated: true}, // 40140110 应用已过期 + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197667", AppName: "观影", DisplayName: "观影"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197669", AppName: "SRY", DisplayName: "SRY"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197673", AppName: "nice", DisplayName: "nice"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197675", AppName: "115sync", DisplayName: "115sync"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197677", AppName: "随身听", DisplayName: "随身听"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197679", AppName: "my-list", DisplayName: "my-list"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197681", AppName: "flux", DisplayName: "flux"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197689", AppName: "115测试", DisplayName: "115测试"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197691", AppName: "招财猫115", DisplayName: "招财猫115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197695", AppName: "心雨佳", DisplayName: "心雨佳"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197697", AppName: "HamHub播放器", DisplayName: "HamHub播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197699", AppName: "cms-0079", DisplayName: "cms-0079"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197701", AppName: "个人云数据备份", DisplayName: "个人云数据备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197703", AppName: "cgrclone", DisplayName: "cgrclone"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197705", AppName: "终极云盘精灵(绿联NAS版)", DisplayName: "终极云盘精灵(绿联NAS版)"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197707", AppName: "测试1985", DisplayName: "测试1985"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197709", AppName: "CMS影视", DisplayName: "CMS影视"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197711", AppName: "个人双向同步nas文件工具", DisplayName: "个人双向同步nas文件工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197713", AppName: "终极云盘互联(NasOL版)", DisplayName: "终极云盘互联(NasOL版)"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197715", AppName: "本地备份工具", DisplayName: "本地备份工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197721", AppName: "绿联云文件管理器", DisplayName: "绿联云文件管理器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197723", AppName: "tgto123", DisplayName: "tgto123"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197725", AppName: "云盘资源", DisplayName: "云盘资源"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197727", AppName: "速传宝", DisplayName: "速传宝"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197729", AppName: "VabHub", DisplayName: "VabHub"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197731", AppName: "存好", DisplayName: "存好"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197733", AppName: "PhotoMagic", DisplayName: "PhotoMagic"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197735", AppName: "cpan115", DisplayName: "cpan115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197737", AppName: "音乐标签编辑器", DisplayName: "音乐标签编辑器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197739", AppName: "私密盒子", DisplayName: "私密盒子"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197741", AppName: "alpha audio", DisplayName: "alpha audio"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197743", AppName: "家庭 115 资源同步工具", DisplayName: "家庭 115 资源同步工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197745", AppName: "京鲸115bot", DisplayName: "京鲸115bot"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197749", AppName: "myblog", DisplayName: "myblog"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197751", AppName: "whhlocal", DisplayName: "whhlocal"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197753", AppName: "raisrais", DisplayName: "raisrais"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197757", AppName: "CMS 剧乐部", DisplayName: "CMS 剧乐部"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197759", AppName: "openlist 资料流转工具", DisplayName: "openlist 资料流转工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197761", AppName: "HARMONY", DisplayName: "HARMONY"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197763", AppName: "gitto_oplist", DisplayName: "gitto_oplist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197765", AppName: "callme", DisplayName: "callme"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197767", AppName: "115play", DisplayName: "115play"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197769", AppName: "AWMedia", DisplayName: "AWMedia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197771", AppName: "mediaLib", DisplayName: "mediaLib"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197773", AppName: "魔法师list", DisplayName: "魔法师list"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197775", AppName: "小柠檬云盘管理", DisplayName: "小柠檬云盘管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197777", AppName: "海美迪", DisplayName: "海美迪"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197779", AppName: "AMMDS", DisplayName: "AMMDS"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197781", AppName: "openlistlee", DisplayName: "openlistlee"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197783", AppName: "tg2115", DisplayName: "tg2115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197785", AppName: "盘飞飞", DisplayName: "盘飞飞"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197787", AppName: "影视咖", DisplayName: "影视咖"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197789", AppName: "招财猫zccat", DisplayName: "招财猫zccat"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197791", AppName: "smallxin", DisplayName: "smallxin"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197793", AppName: "smallxin2", DisplayName: "smallxin2"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197795", AppName: "8kzoom", DisplayName: "8kzoom"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197797", AppName: "smallxin3", DisplayName: "smallxin3"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197799", AppName: "FOL", DisplayName: "FOL"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197801", AppName: "Mymedia", DisplayName: "Mymedia"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197803", AppName: "第一个应用", DisplayName: "第一个应用"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197805", AppName: "jiyangwangpan", DisplayName: "jiyangwangpan"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197807", AppName: "MediaToolsAi", DisplayName: "MediaToolsAi"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197811", AppName: "映匣", DisplayName: "映匣"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197815", AppName: "openmovie", DisplayName: "openmovie"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197817", AppName: "网盘文件管理", DisplayName: "网盘文件管理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197819", AppName: "个人云盘助手", DisplayName: "个人云盘助手"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197821", AppName: "demo", DisplayName: "demo"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197823", AppName: "ZotSync115", DisplayName: "ZotSync115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197825", AppName: "spanbin", DisplayName: "spanbin"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197827", AppName: "tg115", DisplayName: "tg115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197831", AppName: "极客家", DisplayName: "极客家"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197833", AppName: "VidHub播放器安卓版", DisplayName: "VidHub播放器安卓版"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197835", AppName: "VidHub播放器安卓TV版", DisplayName: "VidHub播放器安卓TV版"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197837", AppName: "astroin备份", DisplayName: "astroin备份"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197839", AppName: "网盘音乐播放器", DisplayName: "网盘音乐播放器"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197841", AppName: "低调视频播放器1", DisplayName: "低调视频播放器1"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197845", AppName: "AI整理115网盘", DisplayName: "AI整理115网盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197847", AppName: "MoviePilot-115", DisplayName: "MoviePilot-115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197849", AppName: "QMediaSync", DisplayName: "QMediaSync", Pinned: true}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197851", AppName: "alpha115", DisplayName: "alpha115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197853", AppName: "青衣江", DisplayName: "青衣江"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197855", AppName: "Developer", DisplayName: "Developer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197857", AppName: "xtopenlist", DisplayName: "xtopenlist"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197861", AppName: "柒柒的115管理工具", DisplayName: "柒柒的115管理工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197863", AppName: "iplayer", DisplayName: "iplayer"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197865", AppName: "lhp home1", DisplayName: "lhp home1"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197867", AppName: "Refore", DisplayName: "Refore"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197869", AppName: "录播上传", DisplayName: "录播上传"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197873", AppName: "115cms", DisplayName: "115cms"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197877", AppName: "盘内整理", DisplayName: "盘内整理"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197879", AppName: "myOpenList", DisplayName: "myOpenList"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197881", AppName: "ab115", DisplayName: "ab115"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197883", AppName: "绝痕数科", DisplayName: "绝痕数科"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197885", AppName: "管理平台", DisplayName: "管理平台"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197887", AppName: "115new", DisplayName: "115new"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197889", AppName: "悬镜", DisplayName: "悬镜"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197891", AppName: "MyAssets", DisplayName: "MyAssets"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197895", AppName: "alist2", DisplayName: "alist2"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197897", AppName: "meow", DisplayName: "meow"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197899", AppName: "C_M_S", DisplayName: "C_M_S"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197901", AppName: "文件同步工具", DisplayName: "文件同步工具"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197903", AppName: "Navidrome", DisplayName: "Navidrome"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197905", AppName: "离线机器人2", DisplayName: "离线机器人2"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197919", AppName: "蓝破云盘", DisplayName: "蓝破云盘"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197921", AppName: "foam", DisplayName: "foam"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197923", AppName: "xdfrms-1", DisplayName: "xdfrms-1"}, + {SourceType: SourceTypeBuiltInAppID, Provider: ProviderOfficialPKCE, AppID: "100197925", AppName: "自己文件处理2", DisplayName: "自己文件处理2"}, +} + +var builtInAppIDByID map[string]Source + +func init() { + builtInAppIDByID = make(map[string]Source, len(builtInAppIDCatalog)) + for _, source := range builtInAppIDCatalog { + builtInAppIDByID[source.AppID] = source + } +} + +// BuiltInAppIDSources 返回内置应用目录(置顶在前)。 +func BuiltInAppIDSources() []Source { + sources := make([]Source, 0, len(builtInAppIDCatalog)) + for _, source := range builtInAppIDCatalog { + if source.Deprecated || !source.Pinned { + continue + } + sources = append(sources, source) + } + for _, source := range builtInAppIDCatalog { + if source.Deprecated || source.Pinned { + continue + } + sources = append(sources, source) + } + return sources +} + +// BuiltInRelaySources 返回中继授权源(需配置共享 AES 密钥才能使用)。 +func BuiltInRelaySources() []Source { + return []Source{ + {SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: BuiltInRelayQMediaSync, AppName: BuiltInRelayQMediaSync, DisplayName: BuiltInRelayQMediaSync, AuthServer: "https://oauth.qmediasync.cn", RequiresEncryptionKey: true}, + {SourceType: SourceTypeBuiltInRelay, Provider: ProviderMQFamily, AppID: BuiltInRelayQ115STRM, AppName: BuiltInRelayQ115STRM, DisplayName: BuiltInRelayQ115STRM, AuthServer: "https://api.mqfamily.top", RequiresEncryptionKey: true, Deprecated: true}, + {SourceType: SourceTypeBuiltInRelay, Provider: ProviderMQFamily, AppID: BuiltInRelayMQMediaLibrary, AppName: BuiltInRelayMQMediaLibrary, DisplayName: BuiltInRelayMQMediaLibrary, AuthServer: "https://api.mqfamily.top", RequiresEncryptionKey: true, Deprecated: true}, + } +} + +// ThirdPartySources 返回第三方授权源(无需共享密钥)。 +func ThirdPartySources() []Source { + return []Source{ + {SourceType: SourceTypeThirdPartyService, Provider: ProviderMoviePilot, AppID: "100197847", AppName: "MoviePilot-115", DisplayName: "MoviePilot", AuthServer: "https://movie-pilot.org"}, + {SourceType: SourceTypeThirdPartyService, Provider: ProviderCloudDrive, AppID: "100195313", AppName: "CloudDrive", DisplayName: "CloudDrive", RedirectURI: "https://redirect115.zhenyunpan.com"}, + } +} + +// FindSource 按类型/提供方/AppID 查找授权来源。 +func FindSource(sourceType AuthSourceType, provider AuthProvider, appID string) (Source, bool) { + appID = strings.TrimSpace(appID) + switch sourceType { + case SourceTypeBuiltInAppID: + source, ok := builtInAppIDByID[appID] + return source, ok + case SourceTypeBuiltInRelay: + for _, source := range BuiltInRelaySources() { + if provider != "" && source.Provider != provider { + continue + } + if appID != "" && source.AppID != appID { + continue + } + return source, true + } + case SourceTypeThirdPartyService: + for _, source := range ThirdPartySources() { + if provider != "" && source.Provider != provider { + continue + } + if appID != "" && source.AppID != appID { + continue + } + return source, true + } + } + return Source{}, false +} + +// RelayEncryptionKey 是中继授权回调 payload 的共享 AES 密钥; +// 未配置时 relay 类授权源不可用。可通过设置 strm.115_relay_key 配置。 +var RelayEncryptionKey = "" + +func RelayAvailable() bool { + return strings.TrimSpace(RelayEncryptionKey) != "" +} diff --git a/internal/service/cloud115/utils.go b/internal/service/cloud115/utils.go new file mode 100644 index 0000000..fa7f6b7 --- /dev/null +++ b/internal/service/cloud115/utils.go @@ -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) +} diff --git a/internal/service/service.go b/internal/service/service.go index cb21379..25b67d4 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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() } diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 1280642..fd2c8d1 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -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() { diff --git a/internal/service/strm_115_oauth.go b/internal/service/strm_115_oauth.go new file mode 100644 index 0000000..a496184 --- /dev/null +++ b/internal/service/strm_115_oauth.go @@ -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) +} diff --git a/internal/service/strm_115_oauth_test.go b/internal/service/strm_115_oauth_test.go new file mode 100644 index 0000000..45292e2 --- /dev/null +++ b/internal/service/strm_115_oauth_test.go @@ -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") + } +} diff --git a/internal/service/strm_play.go b/internal/service/strm_play.go new file mode 100644 index 0000000..afa7fa1 --- /dev/null +++ b/internal/service/strm_play.go @@ -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 +} diff --git a/internal/service/strm_queue.go b/internal/service/strm_queue.go new file mode 100644 index 0000000..45f707d --- /dev/null +++ b/internal/service/strm_queue.go @@ -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): + } +} diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go new file mode 100644 index 0000000..d02cab9 --- /dev/null +++ b/internal/service/strm_service.go @@ -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" +} diff --git a/internal/service/strm_sync.go b/internal/service/strm_sync.go new file mode 100644 index 0000000..a981b18 --- /dev/null +++ b/internal/service/strm_sync.go @@ -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 +} diff --git a/internal/service/strm_sync_test.go b/internal/service/strm_sync_test.go new file mode 100644 index 0000000..a7b0801 --- /dev/null +++ b/internal/service/strm_sync_test.go @@ -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"), "") + + 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") + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 86d123b..c0d8dc8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index f52ce12..1a128de 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts new file mode 100644 index 0000000..896baa2 --- /dev/null +++ b/web/src/api/strm.ts @@ -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('/admin/strm/accounts').then((r) => r.data), + + createAccount: (payload: StrmAccountInput) => + api.post('/admin/strm/accounts', payload).then((r) => r.data), + + updateAccount: (id: string, payload: StrmAccountInput) => + api.put(`/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(`/admin/strm/accounts/${id}/test`).then((r) => r.data), + + listRemoteDir: (accountId: string, dir: string) => + api + .get(`/admin/strm/accounts/${accountId}/list`, { params: { dir } }) + .then((r) => r.data), + + list115Sources: () => + api.get('/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(`/admin/strm/accounts/${accountId}/oauth/start`, payload).then((r) => r.data), + + poll115OAuth: (accountId: string, sessionId: string) => + api.post(`/admin/strm/accounts/${accountId}/oauth/poll`, { session_id: sessionId }).then((r) => r.data), + + // ── 全局设置 ──────────────────────────────────────────── + getSettings: () => + api.get('/admin/strm/settings').then((r) => r.data), + + updateSettings: (payload: Partial) => + api.put('/admin/strm/settings', payload).then((r) => r.data), + + // ── 同步目录 ──────────────────────────────────────────── + listPaths: () => api.get('/admin/strm/paths').then((r) => r.data), + + createPath: (payload: StrmSyncPathInput) => + api.post('/admin/strm/paths', payload).then((r) => r.data), + + updatePath: (id: string, payload: StrmSyncPathInput) => + api.put(`/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('/admin/strm/records', { params: pathId ? { path_id: pathId } : {} }) + .then((r) => r.data), + + // ── 本地目录浏览(同步目录选择器) ──────────────────────── + listLocalDirs: (path?: string) => + api + .get('/admin/strm/local-dirs', { params: path ? { path } : {} }) + .then((r) => r.data), + + // ── 下载/上传队列 ─────────────────────────────────────── + downloads: (status?: string, page = 1, pageSize = 50) => + api + .get('/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('/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), +} \ No newline at end of file diff --git a/web/src/appRoutes.tsx b/web/src/appRoutes.tsx index 331fe44..67a14c0 100644 --- a/web/src/appRoutes.tsx +++ b/web/src/appRoutes.tsx @@ -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: }, { path: 'files', element: , adminOnly: true }, { path: 'settings', element: , adminOnly: true }, + { path: 'strm', element: , adminOnly: true }, + { path: 'strm/downloads', element: , adminOnly: true }, + { path: 'strm/uploads', element: , adminOnly: true }, { path: 'admin', element: , adminOnly: true }, ] diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts index 5887c2d..bd787f0 100644 --- a/web/src/components/layoutNavigation.ts +++ b/web/src/components/layoutNavigation.ts @@ -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 }, ] \ No newline at end of file diff --git a/web/src/pages/StrmDialogs.tsx b/web/src/pages/StrmDialogs.tsx new file mode 100644 index 0000000..a1ccbb8 --- /dev/null +++ b/web/src/pages/StrmDialogs.tsx @@ -0,0 +1,1156 @@ +import { FormEvent, useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { + ChevronRight, + Cloud, + ExternalLink, + FolderPlus, + HardDrive, + Loader2, + QrCode, + RefreshCw, + Search, + X, +} from 'lucide-react' + +import QRCode from 'qrcode' + +import { strmAPI, type Strm115Source, type StrmRemoteEntry } from '../api/strm' +import type { SettingDef } from './SettingsRow' +import { SettingRow } from './SettingsRow' +import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm' +import { STRM_PROVIDER_LABELS } from '../types/strm' +import { apiErrorMessage } from './StrmManagePage' + +// ─── 弹框外壳 ──────────────────────────────────────────────────────────────── + +function DialogShell({ + title, + subtitle, + children, + onClose, + wide, +}: { + title: string + subtitle?: string + children: React.ReactNode + onClose: () => void + wide?: boolean +}) { + return ( +
+
event.stopPropagation()} + > +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
+
{children}
+
+
+ ) +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( + + ) +} + +const inputCls = 'input-base w-full' + +// ─── 添加/编辑网盘账号 ──────────────────────────────────────────────────────── + +const PROVIDER_OPTIONS: { provider: StrmProvider; label: string; desc: string }[] = [ + { provider: 'cloud115', label: '115 网盘', desc: '开放平台 OAuth 授权(扫码/网页)' }, + { provider: 'clouddrive2', label: 'CloudDrive2', desc: 'WebDAV 桥接多种网盘' }, + { provider: 'openlist', label: 'OpenList', desc: 'OpenList / AList 兼容服务' }, +] + +export function StrmAccountDialog({ + existing, + onClose, + onSaved, +}: { + existing: StrmAccount | null + onClose: () => void + onSaved: () => void +}) { + const [provider, setProvider] = useState(existing?.provider ?? 'cloud115') + const [name, setName] = useState(existing?.name ?? '') + const [enabled, setEnabled] = useState(existing?.enabled ?? true) + const [url, setUrl] = useState('') + const [server, setServer] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [token, setToken] = useState('') + const [saving, setSaving] = useState(false) + + const buildConfig = (): Record => { + switch (provider) { + case 'clouddrive2': + return { + ...(url ? { url } : {}), + ...(username ? { username } : {}), + ...(password ? { password } : {}), + } + case 'openlist': + return { + ...(server ? { server } : {}), + ...(username ? { username } : {}), + ...(password ? { password } : {}), + ...(token ? { token } : {}), + } + default: + return {} + } + } + + const canSave = () => { + switch (provider) { + case 'clouddrive2': + return url !== '' + case 'openlist': + return server !== '' && (token !== '' || password !== '') + default: + return false + } + } + + const submit = async (event: FormEvent) => { + event.preventDefault() + setSaving(true) + try { + if (existing) { + await strmAPI.updateAccount(existing.id, { + name, + provider: existing.provider, + enabled, + config: existing.has_credential && !Object.keys(buildConfig()).length ? {} : buildConfig(), + }) + toast.success('网盘账号已更新') + } else { + if (!canSave()) { + toast.error('请先完成凭据配置') + return + } + await strmAPI.createAccount({ name, provider, config: buildConfig() }) + toast.success('网盘账号已添加') + } + onSaved() + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setSaving(false) + } + } + + return ( + +
+ {/* 提供方选择 */} + {!existing && ( +
+ {PROVIDER_OPTIONS.map((option) => { + const Icon = option.provider === 'cloud115' ? QrCode : option.provider === 'clouddrive2' ? HardDrive : FolderPlus + const active = provider === option.provider + return ( + + ) + })} +
+ )} + + + setName(e.target.value)} + /> + + + {provider === 'cloud115' && ( + { + toast.success(`「${account.name}」授权成功`) + onSaved() + }} + /> + )} + + {provider === 'clouddrive2' && ( + + setUrl(e.target.value)} /> + + )} + + {provider === 'openlist' && ( + + setServer(e.target.value)} /> + + )} + + {provider !== 'cloud115' && ( +
+ + setUsername(e.target.value)} /> + + + setPassword(e.target.value)} /> + +
+ )} + + {provider === 'openlist' && ( + + setToken(e.target.value)} /> + + )} + + {existing && provider !== 'cloud115' && ( + + )} + + {provider !== 'cloud115' && ( +
+ + +
+ )} + +
+ ) +} + +// ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ─────────── + +// QR 尺寸:物理像素 = CSS 像素(避免 canvas 被 CSS 缩放导致裁切/模糊) +const QR_SIZE = 176 + +function QrCanvas({ content }: { content: string }) { + const canvasRef = useRef(null) + const [error, setError] = useState('') + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || !content) return + QRCode.toCanvas(canvas, content, { + width: QR_SIZE, + margin: 2, + errorCorrectionLevel: 'M', + color: { dark: '#1f2937', light: '#ffffff' }, + }) + .then(() => setError('')) + .catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败')) + }, [content]) + + return ( +
+ + {error && ( +
+ {error} +
+ )} +
+ ) +} + +// ─── 115 开放平台授权面板 ───────────────────────────────────────────────────── + +type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service' + +const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [ + { key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' }, + { key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' }, + { key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true }, + { key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' }, +] + +function Strm115AuthPanel({ + existing, + accountName, + onAuthed, +}: { + existing: StrmAccount | null + accountName: string + onAuthed: (account: StrmAccount) => void +}) { + const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null) + const [relayKeyConfigured, setRelayKeyConfigured] = useState(false) + const [authSource, setAuthSource] = useState('built_in_appid') + const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot') + const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync') + const [appID, setAppID] = useState('100195125') + const [appKeyword, setAppKeyword] = useState('') + + const [starting, setStarting] = useState(false) + const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null) + const [authStatus, setAuthStatus] = useState('') + + const pollRef = useRef | null>(null) + const stopPolling = () => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + } + useEffect(() => stopPolling, []) + + useEffect(() => { + ;(async () => { + try { + const data = await strmAPI.list115Sources() + setSources(data) + const settings = await strmAPI.getSettings().catch(() => null) + const key = settings?.['strm.115_relay_key'] + setRelayKeyConfigured(Boolean(key && key.trim())) + } catch (err) { + toast.error(apiErrorMessage(err)) + } + })() + }, []) + + const filteredApps = (sources?.built_in ?? []).filter( + (s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword), + ) + + const buildStartPayload = () => { + switch (authSource) { + case 'built_in_appid': + return { auth_source: 'built_in_appid', app_id: appID } + case 'custom_appid': + return { auth_source: 'custom_appid', app_id: appID } + case 'built_in_relay': + return { auth_source: 'built_in_relay', provider: relayProvider } + case 'third_party_service': + return { auth_source: 'third_party_service', provider: thirdParty } + } + } + + const startAuth = async () => { + setStarting(true) + setAuthStatus('') + try { + // 115 授权需要账号 ID:没有则先创建空凭据账号 + let account = existing + if (!account) { + account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} }) + } + const result = await strmAPI.start115OAuth(account.id, buildStartPayload()) + setAuthUI( + result.mode === 'qrcode' + ? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode } + : { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url }, + ) + stopPolling() + pollRef.current = setInterval(async () => { + try { + const status = await strmAPI.poll115OAuth(account.id, result.session_id) + setAuthStatus(status.tip) + if (status.status === 'confirmed') { + stopPolling() + const updated = await strmAPI.testAccount(account.id) + onAuthed(updated) + } + if (status.status === 'expired') { + stopPolling() + setAuthUI(null) + toast.error('授权已过期,请重新发起') + } + } catch { + /* 轮询失败等下一次 */ + } + }, 3000) + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setStarting(false) + } + } + + const openAuthWindow = () => { + if (authUI?.mode === 'url' && authUI.authUrl) { + window.open(authUI.authUrl, '_blank', 'noopener') + } + } + + const resetAuth = () => { + stopPolling() + setAuthUI(null) + setAuthStatus('') + } + + return ( +
+ {existing?.has_credential && ( +

+ ✓ 该账号已授权;重新授权会替换现有令牌 +

+ )} + + {!authUI && ( +
+
+ {AUTH_SOURCE_OPTIONS.map((option) => { + const active = authSource === option.key + const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured) + return ( + + ) + })} +
+ + {authSource === 'built_in_appid' && ( +
+ +
+ + setAppKeyword(e.target.value)} /> +
+ +
+
+ )} + {authSource === 'custom_appid' && ( + + setAppID(e.target.value)} /> + + )} + {authSource === 'built_in_relay' && ( + + + + )} + {authSource === 'third_party_service' && ( + + + + )} + + +
+ )} + + {authUI && ( +
+ {authUI.mode === 'qrcode' && authUI.qrcodeUrl ? ( +
+ +
+

{authStatus || '等待扫码…'}

+

请使用 115 手机客户端扫码并确认授权,5 分钟内有效

+
+
+ ) : ( +
+

请点击下方按钮在新窗口完成授权:

+ +

完成后回到此页面等待自动确认(授权成功后可关闭弹窗)

+
+ )} +
+ + {authStatus} +
+ +
+ )} +
+ ) +} + +// ─── STRM 设置 ──────────────────────────────────────────────────────────────── + +const SETTING_DEFS: SettingDef[] = [ + { key: 'strm.base_url', label: 'STRM 链接基础地址', type: 'text', hint: '生成的 strm 文件指向的播放地址;留空依次自动使用 app.server_url,最后回退到本机地址(http://127.0.0.1:端口)。Emby 在其他设备时请配置局域网/公网地址' }, + { key: 'strm.video_ext', label: '视频扩展名', type: 'text', hint: '逗号分隔,命中即生成 .strm' }, + { key: 'strm.meta_ext', label: '元数据扩展名', type: 'text', hint: '逗号分隔,进入下载/上传队列(nfo/图片/字幕)' }, + { key: 'strm.exclude_name', label: '排除文件名', type: 'text', hint: '逗号分隔,文件名包含任一关键词即跳过' }, + { key: 'strm.min_video_size_mb', label: '最小视频大小(MB)', type: 'number', hint: '小于该大小的视频不生成 STRM,0 表示不限' }, + { + key: 'strm.add_path', + label: 'STRM 链接 path 参数', + type: 'select', + hint: '1=附带完整远端路径 2=仅文件名 3=不带 path', + options: [ + { value: '1', label: '完整路径' }, + { value: '2', label: '仅文件名' }, + { value: '3', label: '不带 path' }, + ], + }, + { key: 'strm.115_relay_key', label: '115 中继授权共享密钥', type: 'text', hint: 'QMediaSync/MQFamily 中继授权的共享 AES 密钥;不配置则中继授权不可用' }, + { key: 'strm.download_threads', label: '下载队列线程数', type: 'number', hint: '元数据下载并发数' }, + { key: 'strm.upload_threads', label: '上传队列线程数', type: 'number', hint: '元数据上传并发数' }, +] + +export function StrmSettingsDialog({ onClose }: { onClose: () => void }) { + const [values, setValues] = useState | null>(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + strmAPI + .getSettings() + .then(setValues) + .catch((err) => toast.error(apiErrorMessage(err))) + }, []) + + const submit = async (event: FormEvent) => { + event.preventDefault() + if (!values) return + setSaving(true) + try { + await strmAPI.updateSettings(values) + toast.success('STRM 设置已保存') + onClose() + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setSaving(false) + } + } + + return ( + + {!values ? ( +
+ +
+ ) : ( +
+ {SETTING_DEFS.map((def) => ( + setValues((v) => ({ ...v!, [def.key]: value }))} + /> + ))} +
+ + +
+ + )} +
+ ) +} + +// ─── 添加/编辑同步目录 ──────────────────────────────────────────────────────── + +export function StrmSyncPathDialog({ + accounts, + existing, + onClose, + onSaved, +}: { + accounts: StrmAccount[] + existing: StrmSyncPath | null + onClose: () => void + onSaved: () => void +}) { + const [form, setForm] = useState(() => ({ + name: existing?.name ?? '', + provider: existing?.provider ?? 'cloud115', + account_id: existing?.account_id ?? '', + remote_path: existing?.remote_path ?? '', + local_path: existing?.local_path ?? '', + strm_base_url: existing?.strm_base_url ?? '', + video_ext: existing?.video_ext ?? '', + meta_ext: existing?.meta_ext ?? '', + exclude_name: existing?.exclude_name ?? '', + min_video_size_mb: existing?.min_video_size_mb ?? 0, + add_path: existing?.add_path ?? 1, + download_meta: existing?.download_meta ?? true, + upload_meta: existing?.upload_meta ?? false, + delete_dir: existing?.delete_dir ?? false, + cron: existing?.cron ?? '', + enable_cron: existing?.enable_cron ?? false, + enabled: existing?.enabled ?? true, + })) + const [saving, setSaving] = useState(false) + const [browsing, setBrowsing] = useState(false) + const [browsingLocal, setBrowsingLocal] = useState(null) + + const set = (key: K, value: StrmSyncPathInput[K]) => + setForm((f) => ({ ...f, [key]: value })) + + const isLocal = form.provider === 'local' + const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setSaving(true) + try { + if (existing) { + await strmAPI.updatePath(existing.id, form) + toast.success('同步目录已更新') + } else { + await strmAPI.createPath(form) + toast.success('同步目录已添加') + } + onSaved() + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setSaving(false) + } + } + + return ( + +
+ + set('name', e.target.value)} /> + + +
+ + + + {!isLocal && ( + + + + )} +
+ +
+
+
+ + set('remote_path', e.target.value)} /> + +
+ {isLocal && ( + + )} + {!isLocal && form.account_id && ( + + )} +
+
+ +
+
+
+ + set('local_path', e.target.value)} /> + +
+ +
+
+ +
+ 高级选项 +
+
+ + set('strm_base_url', e.target.value)} /> + + + set('min_video_size_mb', Number(e.target.value))} + /> + +
+
+ + set('video_ext', e.target.value)} /> + + + set('meta_ext', e.target.value)} /> + + + set('exclude_name', e.target.value)} /> + +
+
+ + + + + set('cron', e.target.value)} /> + +
+
+ set('download_meta', v)} /> + set('upload_meta', v)} /> + set('delete_dir', v)} /> + set('enable_cron', v)} /> + set('enabled', v)} /> +
+
+
+ +
+ + +
+
+ + {browsing && form.account_id && ( + { + set('remote_path', id) + setBrowsing(false) + }} + onClose={() => setBrowsing(false)} + /> + )} + + {browsingLocal && ( + { + set(browsingLocal, path) + setBrowsingLocal(null) + }} + onClose={() => setBrowsingLocal(null)} + /> + )} +
+ ) +} + +function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +// ─── 本地目录浏览选择器(Windows 盘符 / Linux 根目录兼容) ───────────────────── + +function LocalDirBrowserDialog({ + initialDir, + onSelect, + onClose, +}: { + initialDir?: string + onSelect: (path: string) => void + onClose: () => void +}) { + const [list, setList] = useState<{ roots: boolean; parent?: string; current?: string; children: { name: string; path: string }[] } | null>(null) + const [loading, setLoading] = useState(true) + + const load = async (target: string) => { + setLoading(true) + try { + const data = await strmAPI.listLocalDirs(target) + setList(data) + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setLoading(false) + } + } + + useEffect(() => { + load(initialDir ?? '').catch(() => undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const selectable = list && !list.roots && list.current + + return ( +
+
event.stopPropagation()} + > +
+

选择本地目录

+ +
+
+ {list?.roots ? ( + {'选择盘符 / 根目录'} + ) : ( + <> + + + + {list?.current} + + + )} +
+
+ {loading ? ( +
+ +
+ ) : !list || list.children.length === 0 ? ( +

该目录下没有子目录

+ ) : ( +
+ {(list.parent ?? '') !== '' && (!list.roots || true) && ( + + )} + {list.children.map((entry) => ( + + ))} +
+ )} +
+
+ 单击目录进入下一级,最后点击「选择此目录」回填路径 + +
+
+
+ ) +} + +// ─── 远端目录浏览选择器 ─────────────────────────────────────────────────────── + +function StrmDirBrowserDialog({ + accountId, + initialDir, + onSelect, + onClose, +}: { + accountId: string + initialDir?: string + onSelect: (path: string) => void + onClose: () => void +}) { + const [dir, setDir] = useState(initialDir ?? '') + const [crumbs, setCrumbs] = useState([]) + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + + const load = async (target: string) => { + setLoading(true) + try { + const list = await strmAPI.listRemoteDir(accountId, target) + setEntries(list) + setDir(target) + setCrumbs(target ? target.split('/').filter(Boolean) : []) + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setLoading(false) + } + } + + useEffect(() => { + load(initialDir ?? '').catch(() => undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountId]) + + const enterDir = (id: string) => load(id).catch(() => undefined) + + return ( +
+
event.stopPropagation()} + > +
+

选择远端目录

+ +
+
+ + {crumbs.map((crumb, index) => ( + + + {crumb} + + ))} + {dir || '(根目录 / 0)'} +
+
+ {loading ? ( +
+ +
+ ) : entries.length === 0 ? ( +

该目录为空

+ ) : ( +
+ {entries.map((entry) => { + const Icon = entry.is_dir ? FolderPlus : HardDrive + return ( + + ) : ( + {formatEntrySize(entry.size)} + )} + + ) + })} +
+ )} +
+
+ 双击进入目录,点击「选择此目录」使用该目录 ID / 路径 + +
+
+
+ ) +} + +function formatEntrySize(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]}` +} \ No newline at end of file diff --git a/web/src/pages/StrmManagePage.tsx b/web/src/pages/StrmManagePage.tsx new file mode 100644 index 0000000..357f707 --- /dev/null +++ b/web/src/pages/StrmManagePage.tsx @@ -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 = { + 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 = { + 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([]) + const [paths, setPaths] = useState([]) + const [records, setRecords] = useState([]) + const [loading, setLoading] = useState(true) + const [dialog, setDialog] = useState(null) + const [editingPath, setEditingPath] = useState(null) + const [editingAccount, setEditingAccount] = useState(null) + const [actingPath, setActingPath] = useState(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 ( +
+
+
+
+ +
+
+

STRM 管理

+

+ 网盘目录同步生成 .strm 文件(Emby / Jellyfin 直接刮削播放),元数据经下载 / 上传队列双向同步 +

+
+
+
+ + + +
+
+ + {loading ? ( +
+ +
+ ) : ( + <> + { setEditingAccount(null); setDialog('account') }} + onEdit={(account) => { setEditingAccount(account); setDialog('account') }} + onDelete={deleteAccount} + onTest={testAccount} + /> + + { setEditingPath(null); setDialog('path') }} + onEdit={(path) => { setEditingPath(path); setDialog('path') }} + onDelete={deletePath} + onStart={startSync} + onCancel={cancelSync} + /> + + + + )} + + {dialog === 'account' && ( + setDialog(null)} + onSaved={() => { setDialog(null); refresh().catch(() => undefined) }} + /> + )} + {dialog === 'settings' && setDialog(null)} />} + {dialog === 'path' && ( + setDialog(null)} + onSaved={() => { setDialog(null); refresh().catch(() => undefined) }} + /> + )} +
+ ) +} + +// ─── 网盘账号 ──────────────────────────────────────────────────────────────── + +function AccountSection({ + accounts, + onAdd, + onEdit, + onDelete, + onTest, +}: { + accounts: StrmAccount[] + onAdd: () => void + onEdit: (account: StrmAccount) => void + onDelete: (account: StrmAccount) => void + onTest: (account: StrmAccount) => void +}) { + return ( +
+
+
+ +

网盘账号

+
+ +
+ {accounts.length === 0 ? ( +

+ 还没有网盘账号,点击「添加网盘账号」创建(115 网盘支持二维码登录) +

+ ) : ( +
+ {accounts.map((account) => { + const Icon = providerIcon(account.provider) + return ( +
+
+
+
+ +
+
+

{account.name}

+

{account.provider_label}

+
+
+ + {account.enabled ? '已启用' : '已停用'} + +
+
+

+ 凭据: + {account.has_credential ? ( + 已配置 + ) : ( + 未配置 + )} +

+ {account.last_test_at && ( +

+ 最近测试:{account.last_test_ok ? '正常' : account.last_test_result} +

+ )} +
+
+ + + +
+
+ ) + })} +
+ )} +
+ ) +} + +// ─── 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 ( +
+
+
+ +

STRM 同步目录

+ {paths.length} +
+ +
+ {paths.length === 0 ? ( +

+ 还没有同步目录。添加后系统会把网盘 / 本地目录里的视频生成 .strm 文件到本地输出目录 +

+ ) : ( +
+ {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 ( +
+
+
+
+ +
+
+
+

+ {path.name} + {!path.enabled && (已停用)} +

+ + {running && } + {status.label} + +
+

+ {STRM_PROVIDER_LABELS[path.provider]} + {path.account_name ? ` · ${path.account_name}` : ''} + {path.enable_cron && path.cron ? ` · 定时 ${path.cron}` : ''} +

+
+
+
+ {running ? ( + + ) : ( + + )} + + +
+
+
+ {path.remote_path || '(根目录)'} + → + {path.local_path} +
+ {path.last_sync_message && ( +

{path.last_sync_message}

+ )} +
+ ) + })} +
+ )} +
+ ) +} + +// ─── 同步记录 ──────────────────────────────────────────────────────────────── + +function RecordSection({ records }: { records: StrmSyncRecord[] }) { + return ( +
+
+ +

同步记录

+ {records.length} +
+ {records.length === 0 ? ( +

还没有同步记录

+ ) : ( +
+ + + + + + + + + + + + + + {records.map((record) => { + const meta = RECORD_STATUS_META[record.status] ?? RECORD_STATUS_META.pending + return ( + + + + + + + + + + ) + })} + +
时间状态扫描文件新增 strm下载元数据清理说明
+ {formatTime(record.started_at ?? record.created_at)} + + + {meta.label} + + {record.total}{record.new_strm}{record.new_meta}{record.pruned}{record.message}
+
+ )} +
+ ) +} + +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 ( +
+ + {label} + {value} +
+ ) +} + +// 队列状态徽章 +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 } \ No newline at end of file diff --git a/web/src/pages/StrmQueuePage.tsx b/web/src/pages/StrmQueuePage.tsx new file mode 100644 index 0000000..ab9b09b --- /dev/null +++ b/web/src/pages/StrmQueuePage.tsx @@ -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(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) => ( + + ) + + 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 ( +
+
+
+ +
+
+

+ {isDownload ? '下载队列' : '上传队列'} +

+

+ {isDownload + ? 'STRM 元数据下载情况(远端网盘 → 本地输出目录,3 秒自动刷新)' + : 'STRM 元数据上传情况(本地 → 远端网盘,3 秒自动刷新)'} +

+
+ {isDownload && ( + + )} + +
+ +
+ + + + + +
+ +
+ {FILTERS.map((item) => ( + + ))} + {batchActionByFilter()} +
+ +
+ {loading ? ( +
+ +
+ ) : tasks.length === 0 ? ( +

+ {filter === 'all' ? (isDownload ? '暂无元数据下载任务' : '暂无元数据上传任务') : '该状态下暂无任务'} +

+ ) : ( +
+ + + + + + + + + + + + + + + {tasks.map((task) => { + const status = taskStatusMeta(task.status) + return ( + + + + + + + + + + + ) + })} + +
文件提供方{isDownload ? '本地目标' : '本地来源'}远端路径大小状态创建时间操作
{task.file_name} + {STRM_PROVIDER_LABELS[task.provider] ?? task.provider} + {task.retry_count > 0 && ( + 重试 {task.retry_count} + )} + {task.local_path}{task.remote_path}{formatBytes(task.size)} + + {task.status === 'running' && } + {status.label} + + {task.error && ( +

+ {task.error} +

+ )} +
{formatTime(task.created_at)} + {(task.status === 'pending' || task.status === 'running') && ( + + )} + {(task.status === 'failed' || task.status === 'canceled') && ( + + )} +
+
+ )} + {(snapshot?.total ?? 0) > 0 && ( +
+ + 共 {snapshot?.total ?? 0} 条 · 第 {page} / {totalPages} 页 + +
+ + +
+
+ )} +
+
+ ) +} + +function StatPill({ label, value, cls }: { label: string; value: number; cls: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +export function StrmDownloadQueuePage() { + return +} + +export function StrmUploadQueuePage() { + return +} \ No newline at end of file diff --git a/web/src/types/strm.ts b/web/src/types/strm.ts new file mode 100644 index 0000000..e5ef4b2 --- /dev/null +++ b/web/src/types/strm.ts @@ -0,0 +1,148 @@ +// STRM 管理相关类型定义(与后端 internal/model/strm.go 对应)。 +export type StrmProvider = 'cloud115' | 'clouddrive2' | 'openlist' | 'local' + +export const STRM_PROVIDER_LABELS: Record = { + 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 + 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 \ No newline at end of file