mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d96782622d |
@@ -62,6 +62,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
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/accounts/:id/resolve", resolveStrmRemoteDirHandler(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))
|
||||
|
||||
+62
-36
@@ -3,6 +3,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -172,6 +173,19 @@ func listStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveStrmRemoteDirHandler 按远端目录引用(115 为目录 ID)反查完整展示路径。
|
||||
func resolveStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
dir := strings.TrimSpace(c.Query("dir"))
|
||||
path, err := svc.Strm.ResolveRemoteDirPath(c.Request.Context(), c.Param("id"), dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"path": path})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func getStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -203,24 +217,25 @@ func updateStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// ─── 同步目录 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
AccountID string `json:"account_id"`
|
||||
Provider string `json:"provider"`
|
||||
RemotePath string `json:"remote_path"`
|
||||
RemoteDisplayPath string `json:"remote_display_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"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type strmSyncPathView struct {
|
||||
@@ -240,6 +255,16 @@ func strmSyncPathViews(svc *service.Container, c *gin.Context, paths []model.Str
|
||||
view.AccountEnabled = acct.Enabled
|
||||
}
|
||||
}
|
||||
// 历史 115 数据若尚未记录展示路径,尝试反查一次并回写数据库自愈
|
||||
if p.Provider == model.StrmProvider115 && strings.TrimSpace(p.RemoteDisplayPath) == "" && strings.TrimSpace(p.RemotePath) != "" && p.AccountID != "" {
|
||||
resolveCtx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
if fullPath, err := svc.Strm.ResolveRemoteDirPath(resolveCtx, p.AccountID, p.RemotePath); err == nil && fullPath != "" {
|
||||
view.RemoteDisplayPath = fullPath
|
||||
p.RemoteDisplayPath = fullPath
|
||||
_ = svc.Repo.StrmSyncPath.Update(context.Background(), &p)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
return out
|
||||
@@ -663,24 +688,25 @@ func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 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),
|
||||
SyncMode: strings.TrimSpace(req.SyncMode),
|
||||
Enabled: boolValue(req.Enabled, true),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
Provider: strings.TrimSpace(req.Provider),
|
||||
RemotePath: strings.TrimSpace(req.RemotePath),
|
||||
RemoteDisplayPath: strings.TrimSpace(req.RemoteDisplayPath),
|
||||
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),
|
||||
SyncMode: strings.TrimSpace(req.SyncMode),
|
||||
Enabled: boolValue(req.Enabled, true),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,11 @@ type StrmSyncPath struct {
|
||||
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/元数据本地输出目录
|
||||
// RemoteDisplayPath 是远端目录的完整展示路径(如 /电影/剧集)。115 的
|
||||
// RemotePath 是目录 ID,用户无法辨认,浏览选择或按 ID 反查时把人类可读
|
||||
// 路径存到这里;路径型网盘(CD2/OpenList)与 local 留空(RemotePath 即路径)。
|
||||
RemoteDisplayPath string `gorm:"size:1024" json:"remote_display_path"`
|
||||
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
|
||||
|
||||
@@ -95,28 +95,29 @@ func (r *StrmSyncPathRepository) List(ctx context.Context) ([]model.StrmSyncPath
|
||||
func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPath) error {
|
||||
return withSQLiteBusyRetry(ctx, func() 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,
|
||||
"sync_mode": p.SyncMode,
|
||||
"enabled": p.Enabled,
|
||||
"last_sync_at": p.LastSyncAt,
|
||||
"last_sync_status": p.LastSyncStatus,
|
||||
"last_sync_message": p.LastSyncMessage,
|
||||
"updated_at": time.Now(),
|
||||
"name": p.Name,
|
||||
"account_id": p.AccountID,
|
||||
"provider": p.Provider,
|
||||
"remote_path": p.RemotePath,
|
||||
"remote_display_path": p.RemoteDisplayPath,
|
||||
"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,
|
||||
"sync_mode": p.SyncMode,
|
||||
"enabled": p.Enabled,
|
||||
"last_sync_at": p.LastSyncAt,
|
||||
"last_sync_status": p.LastSyncStatus,
|
||||
"last_sync_message": p.LastSyncMessage,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -37,10 +37,10 @@ type strmSyncState struct {
|
||||
syncType string
|
||||
|
||||
mu sync.Mutex
|
||||
processed int // 已处理文件计数(用于定期落库进度)
|
||||
lastProgressFlush time.Time // 上次进度落库时间
|
||||
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
|
||||
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
|
||||
processed int // 已处理文件计数(用于定期落库进度)
|
||||
lastProgressFlush time.Time // 上次进度落库时间
|
||||
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
|
||||
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
|
||||
remoteMeta map[string]int64 // 远端元数据大小(上传比对用)
|
||||
remoteMetaRef map[string]string // "m:"+相对路径 → 远端元数据文件引用(115 文件 ID,覆盖上传前删除旧文件用)
|
||||
remoteMetaSha1 map[string]string // "m:"+相对路径 → 远端元数据内容 SHA1(115 列表返回;上传/下载精确比对用,其他网盘为空)
|
||||
@@ -184,6 +184,66 @@ func (s *StrmService) ListRemoteDir(ctx context.Context, accountID, dir string)
|
||||
return provider.List(ctx, dir)
|
||||
}
|
||||
|
||||
// ResolveRemoteDirPath 解析远端目录的完整展示路径。115 的目录以 ID 存储,
|
||||
// 用户无法辨认,这里按 ID 反查 115 返回的祖先链拼出人类可读路径;路径型
|
||||
// 网盘(CD2/OpenList)与本地目录的 remote_path 本身就是路径,原样返回。
|
||||
func (s *StrmService) ResolveRemoteDirPath(ctx context.Context, accountID, dir string) (string, error) {
|
||||
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
|
||||
if err != nil || acct == nil {
|
||||
return "", errNotFoundOr(err, "网盘账号不存在")
|
||||
}
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return "", nil
|
||||
}
|
||||
if acct.Provider != model.StrmProvider115 {
|
||||
return dir, nil
|
||||
}
|
||||
if dir == "0" {
|
||||
return "", nil
|
||||
}
|
||||
provider, err := s.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
|
||||
if !ok {
|
||||
return "", fmt.Errorf("115: 客户端初始化失败")
|
||||
}
|
||||
detail, err := oc.OpenClient().GetFsDetailByCid(ctx, dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("115: 解析目录路径失败:%w", err)
|
||||
}
|
||||
return cloud115FullPath(detail), nil
|
||||
}
|
||||
|
||||
// cloud115FullPath 把 115 目录详情的祖先链拼成以 / 开头的完整路径。
|
||||
func cloud115FullPath(detail *cloud115.RemoteFileDetail) string {
|
||||
if detail == nil {
|
||||
return ""
|
||||
}
|
||||
segments := make([]string, 0, len(detail.Paths)+1)
|
||||
hasSelf := false
|
||||
for _, p := range detail.Paths {
|
||||
if p.FileId == "0" || p.FileId == "" {
|
||||
continue
|
||||
}
|
||||
if p.FileId == detail.FileId {
|
||||
hasSelf = true
|
||||
}
|
||||
if name := strings.TrimSpace(p.Name); name != "" {
|
||||
segments = append(segments, name)
|
||||
}
|
||||
}
|
||||
if !hasSelf && strings.TrimSpace(detail.FileName) != "" {
|
||||
segments = append(segments, strings.TrimSpace(detail.FileName))
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "/" + strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
// runSync 执行同步主体;结束时更新记录与目录状态。
|
||||
func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *model.StrmSyncRecord, cancel context.CancelFunc) {
|
||||
defer s.clearRunning(p.ID, cancel)
|
||||
@@ -819,9 +879,9 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
}
|
||||
fileCh := make(chan strmFileTask)
|
||||
var (
|
||||
procWg sync.WaitGroup
|
||||
procWg sync.WaitGroup
|
||||
procErrMu sync.Mutex
|
||||
procErr error
|
||||
procErr error
|
||||
)
|
||||
for i := 0; i < strmProcessWorkers; i++ {
|
||||
procWg.Add(1)
|
||||
|
||||
@@ -1161,3 +1161,48 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
|
||||
t.Fatalf("incremental sync should skip all, new = %d skipped = %d", st2.rec.NewStrm, st2.rec.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115FullPath(t *testing.T) {
|
||||
paths := func(ids, names []string) []struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
} {
|
||||
out := make([]struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
}, 0, len(ids))
|
||||
for i := range ids {
|
||||
out = append(out, struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
}{FileId: ids[i], Name: names[i]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 祖先链已包含当前目录自身(115 常规返回)
|
||||
d := &cloud115.RemoteFileDetail{
|
||||
FileId: "330",
|
||||
FileName: "剧集",
|
||||
Paths: paths([]string{"0", "100", "330"}, []string{"", "电影", "剧集"}),
|
||||
}
|
||||
if got := cloud115FullPath(d); got != "/电影/剧集" {
|
||||
t.Fatalf("full path = %q, want /电影/剧集", got)
|
||||
}
|
||||
|
||||
// 祖先链不含当前目录自身,用 FileName 兜底
|
||||
d = &cloud115.RemoteFileDetail{
|
||||
FileId: "330",
|
||||
FileName: "剧集",
|
||||
Paths: paths([]string{"0", "100"}, []string{"", "电影"}),
|
||||
}
|
||||
if got := cloud115FullPath(d); got != "/电影/剧集" {
|
||||
t.Fatalf("full path = %q, want /电影/剧集", got)
|
||||
}
|
||||
|
||||
// 根目录:无有效祖先段
|
||||
d = &cloud115.RemoteFileDetail{FileId: "0"}
|
||||
if got := cloud115FullPath(d); got != "" {
|
||||
t.Fatalf("root full path = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,12 @@ export const strmAPI = {
|
||||
.get<StrmRemoteEntry[]>(`/admin/strm/accounts/${accountId}/list`, { params: { dir } })
|
||||
.then((r) => r.data),
|
||||
|
||||
// 按远端目录引用(115 为目录 ID)反查完整展示路径
|
||||
resolveRemoteDirPath: (accountId: string, dir: string) =>
|
||||
api
|
||||
.get<{ path: string }>(`/admin/strm/accounts/${accountId}/resolve`, { params: { dir } })
|
||||
.then((r) => r.data.path),
|
||||
|
||||
list115Sources: () =>
|
||||
api.get<Strm115Sources>('/admin/strm/115/sources').then((r) => r.data),
|
||||
|
||||
|
||||
+161
-45
@@ -29,7 +29,7 @@ import {
|
||||
normalizeEmbyRemoteLines,
|
||||
type EmbyRemoteLine,
|
||||
} from '../utils/embyRemoteLines'
|
||||
import { lastPathSegment, syncLocalPathWithRemote } from '../utils/strmPaths'
|
||||
import { lastPathSegment, remoteTailNameOf, syncLocalPathWithRemote } from '../utils/strmPaths'
|
||||
|
||||
// ─── 弹框外壳 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -573,22 +573,33 @@ export function StrmSettingsDialog({ onClose }: { onClose: () => void }) {
|
||||
|
||||
// ─── 添加/编辑同步目录 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 推断已有配置当前实际拼在本地输出目录末尾的尾段(目录名或 115 目录 ID)。 */
|
||||
function initRemoteTail(existing: StrmSyncPath): string {
|
||||
const cidTail = lastPathSegment(existing.remote_path)
|
||||
const nameTail = existing.remote_display_path ? lastPathSegment(existing.remote_display_path) : ''
|
||||
if (nameTail && lastPathSegment(existing.local_path) === nameTail) return nameTail
|
||||
return cidTail
|
||||
}
|
||||
|
||||
export function StrmSyncPathDialog({
|
||||
accounts,
|
||||
existing,
|
||||
onClose,
|
||||
onSaved,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
accounts: StrmAccount[]
|
||||
existing: StrmSyncPath | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onOpenSettings?: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<StrmSyncPathInput>(() => ({
|
||||
name: existing?.name ?? '',
|
||||
provider: existing?.provider ?? 'cloud115',
|
||||
account_id: existing?.account_id ?? '',
|
||||
remote_path: existing?.remote_path ?? '',
|
||||
remote_display_path: existing?.remote_display_path ?? '',
|
||||
local_path: existing?.local_path ?? '',
|
||||
strm_base_url: existing?.strm_base_url ?? '',
|
||||
video_ext: existing?.video_ext ?? '',
|
||||
@@ -607,22 +618,68 @@ export function StrmSyncPathDialog({
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [browsing, setBrowsing] = useState(false)
|
||||
const [browsingLocal, setBrowsingLocal] = useState<null | 'remote_path' | 'local_path'>(null)
|
||||
const prevRemoteTailRef = useRef(existing ? lastPathSegment(existing.remote_path) : '')
|
||||
// 输入框显示的远端目录文本:对于 115 优先显示完整路径,若尚未反查到则显示 ID
|
||||
const [remoteInputValue, setRemoteInputValue] = useState(
|
||||
() => existing?.remote_display_path || existing?.remote_path || '',
|
||||
)
|
||||
// 反查展示路径的序号守卫:远端目录快速连续变更时丢弃过期响应
|
||||
const resolveSeqRef = useRef(0)
|
||||
// prevRemoteTailRef 记录当前拼在本地输出目录末尾、由本弹窗管理的尾段。
|
||||
// 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。
|
||||
const prevRemoteTailRef = useRef(existing ? initRemoteTail(existing) : '')
|
||||
|
||||
const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const updateRemotePath = (remotePath: string) => {
|
||||
const updateRemotePath = (remotePath: string, displayPath?: string) => {
|
||||
const shown = (displayPath || remotePath).trim()
|
||||
setRemoteInputValue(shown)
|
||||
setForm((f) => {
|
||||
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current)
|
||||
const tail = remoteTailNameOf(remotePath, displayPath)
|
||||
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current, tail)
|
||||
prevRemoteTailRef.current = synced.remoteTail
|
||||
return { ...f, remote_path: remotePath, local_path: synced.localPath }
|
||||
return {
|
||||
...f,
|
||||
remote_path: remotePath,
|
||||
remote_display_path: displayPath ?? '',
|
||||
local_path: synced.localPath,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 按 ID 反查完整展示路径。默认仅补展示、不改动已配置的本地输出目录;
|
||||
// resyncTail 用于手动输入 ID 的新配置:把刚拼上的 ID 尾段替换为目录名。
|
||||
const resolveDisplayPath = (accountId: string, remotePath: string, resyncTail = false) => {
|
||||
const seq = ++resolveSeqRef.current
|
||||
strmAPI
|
||||
.resolveRemoteDirPath(accountId, remotePath)
|
||||
.then((fullPath) => {
|
||||
if (seq !== resolveSeqRef.current) return
|
||||
if (fullPath) setRemoteInputValue(fullPath)
|
||||
setForm((f) => {
|
||||
if (f.remote_path !== remotePath) return f
|
||||
if (resyncTail) {
|
||||
const tail = remoteTailNameOf(remotePath, fullPath)
|
||||
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current, tail)
|
||||
prevRemoteTailRef.current = synced.remoteTail
|
||||
return { ...f, remote_display_path: fullPath, local_path: synced.localPath }
|
||||
}
|
||||
// 旧配置的 local_path 末段若恰好就是目录名,把它认作受管尾段,
|
||||
// 后续重新选择目录时才能正确替换而不是叠加
|
||||
const tail = lastPathSegment(fullPath)
|
||||
if (tail && prevRemoteTailRef.current !== tail && lastPathSegment(f.local_path) === tail) {
|
||||
prevRemoteTailRef.current = tail
|
||||
}
|
||||
return { ...f, remote_display_path: fullPath }
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const commitLocalPath = (localPath: string) => {
|
||||
setForm((f) => {
|
||||
const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current)
|
||||
const tail = remoteTailNameOf(f.remote_path, f.remote_display_path)
|
||||
const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current, tail)
|
||||
prevRemoteTailRef.current = synced.remoteTail
|
||||
return { ...f, local_path: synced.localPath }
|
||||
})
|
||||
@@ -630,11 +687,30 @@ export function StrmSyncPathDialog({
|
||||
|
||||
const isLocal = form.provider === 'local'
|
||||
const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential)
|
||||
const remoteDisplayPath = isLocal ? '' : form.remote_display_path?.trim() ?? ''
|
||||
const remoteTail = isLocal ? '' : remoteTailNameOf(form.remote_path, remoteDisplayPath)
|
||||
|
||||
// 编辑旧配置时若尚未存展示路径,按 ID 反查补齐并更新输入框展示
|
||||
useEffect(() => {
|
||||
if (isLocal || form.provider !== 'cloud115') return
|
||||
if (!form.account_id || !form.remote_path || form.remote_display_path) return
|
||||
resolveDisplayPath(form.account_id, form.remote_path)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (!existing) {
|
||||
const settings = await strmAPI.getSettings()
|
||||
if (!settings?.['strm.base_url']?.trim()) {
|
||||
toast.error('未填写strm地址')
|
||||
onClose()
|
||||
onOpenSettings?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (existing) {
|
||||
await strmAPI.updatePath(existing.id, form)
|
||||
toast.success('同步目录已更新')
|
||||
@@ -671,6 +747,9 @@ export function StrmSyncPathDialog({
|
||||
const provider = e.target.value as StrmProvider
|
||||
set('provider', provider)
|
||||
set('account_id', '')
|
||||
set('remote_path', '')
|
||||
set('remote_display_path', '')
|
||||
setRemoteInputValue('')
|
||||
}}
|
||||
>
|
||||
<option value="cloud115">115 网盘</option>
|
||||
@@ -707,17 +786,39 @@ export function StrmSyncPathDialog({
|
||||
isLocal
|
||||
? '扫描该目录下的视频生成 strm'
|
||||
: form.provider === 'cloud115'
|
||||
? '115 目录 ID(可通过浏览选择)'
|
||||
? form.remote_path
|
||||
? `115 目录 ID:${form.remote_path}(可通过右侧「浏览」选择更换)`
|
||||
: '可通过右侧「浏览」选择 115 目录,或直接粘贴目录 ID'
|
||||
: '远端路径(可通过浏览选择)'
|
||||
}
|
||||
>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={form.remote_path}
|
||||
placeholder={isLocal ? 'D:\\movies' : '/'}
|
||||
onChange={(e) => set('remote_path', e.target.value)}
|
||||
value={remoteInputValue}
|
||||
placeholder={isLocal ? 'D:\\movies' : form.provider === 'cloud115' ? '点击右侧「浏览」选择目录' : '/'}
|
||||
onChange={(e) => setRemoteInputValue(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
if (!isLocal) updateRemotePath(e.target.value)
|
||||
const value = e.target.value.trim()
|
||||
if (isLocal) {
|
||||
set('remote_path', value)
|
||||
return
|
||||
}
|
||||
if (form.provider === 'cloud115') {
|
||||
if (!value) {
|
||||
updateRemotePath('', '')
|
||||
return
|
||||
}
|
||||
// 若用户直接输入/粘贴纯数字目录 ID,触发反查并自动替换为完整路径
|
||||
if (/^\d+$/.test(value)) {
|
||||
updateRemotePath(value, '')
|
||||
if (form.account_id) resolveDisplayPath(form.account_id, value, true)
|
||||
return
|
||||
}
|
||||
// 若当前展示的是完整路径且未变动,不做处理
|
||||
if (value === form.remote_display_path) return
|
||||
} else {
|
||||
updateRemotePath(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
@@ -745,8 +846,8 @@ export function StrmSyncPathDialog({
|
||||
<Field
|
||||
label="本地输出目录"
|
||||
hint={
|
||||
!isLocal && lastPathSegment(form.remote_path)
|
||||
? `将自动拼接远端末级目录「${lastPathSegment(form.remote_path)}」`
|
||||
!isLocal && remoteTail
|
||||
? `将自动拼接远端末级目录「${remoteTail}」`
|
||||
: '生成的 .strm 与下载的元数据写到这里的对应目录结构下'
|
||||
}
|
||||
>
|
||||
@@ -774,20 +875,6 @@ export function StrmSyncPathDialog({
|
||||
<details className="rounded-2xl border border-gray-100 bg-gray-50/50 p-3">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-ink-600">高级选项</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="STRM 基础地址(覆盖全局)" hint="留空使用全局 STRM 设置">
|
||||
<input className={inputCls} value={form.strm_base_url ?? ''} placeholder="http://host:port" onChange={(e) => set('strm_base_url', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="最小视频大小(MB)" hint="0 表示继承全局设置">
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.min_video_size_mb ?? 0}
|
||||
onChange={(e) => set('min_video_size_mb', Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Field label="视频扩展名(覆盖全局)">
|
||||
<input className={inputCls} value={form.video_ext ?? ''} placeholder="mkv,mp4,avi" onChange={(e) => set('video_ext', e.target.value)} />
|
||||
@@ -800,6 +887,15 @@ export function StrmSyncPathDialog({
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Field label="最小视频大小(MB)" hint="0 表示继承全局设置">
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.min_video_size_mb ?? 0}
|
||||
onChange={(e) => set('min_video_size_mb', Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="STRM 链接 path 参数">
|
||||
<select className={inputCls} value={form.add_path ?? 1} onChange={(e) => set('add_path', Number(e.target.value))}>
|
||||
<option value={1}>完整远端路径</option>
|
||||
@@ -813,10 +909,10 @@ export function StrmSyncPathDialog({
|
||||
<option value="full">全量同步(全量校验)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="定时同步 Cron" hint="5 段表达式,如 0 */6 * * *">
|
||||
<input className={inputCls} value={form.cron ?? ''} placeholder="0 */6 * * *" onChange={(e) => set('cron', e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="定时同步 Cron" hint="5 段表达式,如 0 */6 * * *">
|
||||
<input className={inputCls} value={form.cron ?? ''} placeholder="0 */6 * * *" onChange={(e) => set('cron', e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<ToggleRow label="下载元数据" checked={form.download_meta ?? true} onChange={(v) => set('download_meta', v)} />
|
||||
<ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} />
|
||||
@@ -842,8 +938,8 @@ export function StrmSyncPathDialog({
|
||||
<StrmDirBrowserDialog
|
||||
accountId={form.account_id}
|
||||
initialDir={form.remote_path || undefined}
|
||||
onSelect={(id) => {
|
||||
updateRemotePath(id)
|
||||
onSelect={(id, _name, fullPath) => {
|
||||
updateRemotePath(id, form.provider === 'cloud115' ? fullPath : undefined)
|
||||
setBrowsing(false)
|
||||
}}
|
||||
onClose={() => setBrowsing(false)}
|
||||
@@ -882,6 +978,12 @@ function ToggleRow({ label, checked, onChange }: { label: string; checked: boole
|
||||
)
|
||||
}
|
||||
|
||||
/** 把浏览时经过的目录名称链拼成以 / 开头的完整展示路径。 */
|
||||
function chainFullPath(chain: { id: string; name: string }[]): string {
|
||||
const names = chain.map((item) => item.name.trim()).filter(Boolean)
|
||||
return names.length > 0 ? '/' + names.join('/') : ''
|
||||
}
|
||||
|
||||
// ─── 远端目录浏览选择器 ───────────────────────────────────────────────────────
|
||||
|
||||
function StrmDirBrowserDialog({
|
||||
@@ -892,25 +994,28 @@ function StrmDirBrowserDialog({
|
||||
}: {
|
||||
accountId: string
|
||||
initialDir?: string
|
||||
onSelect: (path: string) => void
|
||||
onSelect: (id: string, name?: string, fullPath?: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [dir, setDir] = useState(initialDir ?? '')
|
||||
const [crumbs, setCrumbs] = useState<string[]>([])
|
||||
const [entries, setEntries] = useState<StrmRemoteEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 已进入目录的名称链:面包屑按名称展示,「选择当前目录」时回传末级名称
|
||||
const [dirChain, setDirChain] = useState<{ id: string; name: string }[]>([])
|
||||
const dirChainRef = useRef<{ id: string; name: string }[]>([])
|
||||
// 递增序号守卫:快速连续进入目录时丢弃过期目录响应
|
||||
const loadSeqRef = useRef(0)
|
||||
|
||||
const load = async (target: string) => {
|
||||
const load = async (target: string, nextChain?: { id: string; name: string }[]) => {
|
||||
const seq = ++loadSeqRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await strmAPI.listRemoteDir(accountId, target)
|
||||
if (seq !== loadSeqRef.current) return
|
||||
if (nextChain) dirChainRef.current = nextChain
|
||||
setEntries(list)
|
||||
setDir(target)
|
||||
setCrumbs(target ? target.split('/').filter(Boolean) : [])
|
||||
setDirChain([...dirChainRef.current])
|
||||
} catch (err) {
|
||||
if (seq !== loadSeqRef.current) return
|
||||
toast.error(apiErrorMessage(err))
|
||||
@@ -924,7 +1029,13 @@ function StrmDirBrowserDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountId])
|
||||
|
||||
const enterDir = (id: string) => load(id).catch(() => undefined)
|
||||
const enterDir = (entry: StrmRemoteEntry) => {
|
||||
const chain = [...dirChainRef.current]
|
||||
const existingIdx = chain.findIndex((item) => item.id === entry.id)
|
||||
if (existingIdx >= 0) chain.splice(existingIdx + 1)
|
||||
else chain.push({ id: entry.id, name: entry.name })
|
||||
load(entry.id, chain).catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -944,13 +1055,13 @@ function StrmDirBrowserDialog({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 border-b border-gray-100 px-6 py-2.5 text-xs text-sand-500">
|
||||
<button type="button" className="hover:text-brand-500" onClick={() => enterDir('')}>
|
||||
<button type="button" className="hover:text-brand-500" onClick={() => load('', [])}>
|
||||
根目录
|
||||
</button>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={crumb + index} className="flex items-center gap-1">
|
||||
{(dirChain.length > 0 ? dirChain.map((item) => item.name) : dir.split('/').filter(Boolean)).map((label, index) => (
|
||||
<span key={label + index} className="flex items-center gap-1">
|
||||
<ChevronRight size={12} />
|
||||
<span>{crumb}</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-2 text-ink-50">{dir || '(根目录 / 0)'}</span>
|
||||
@@ -971,8 +1082,8 @@ function StrmDirBrowserDialog({
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
|
||||
onClick={() => (entry.is_dir ? enterDir(entry.id) : undefined)}
|
||||
onDoubleClick={() => entry.is_dir && enterDir(entry.id)}
|
||||
onClick={() => (entry.is_dir ? enterDir(entry) : undefined)}
|
||||
onDoubleClick={() => entry.is_dir && enterDir(entry)}
|
||||
>
|
||||
<Icon size={16} className={entry.is_dir ? 'text-brand-400' : 'text-sand-400'} />
|
||||
<span className="flex-1 truncate text-ink-600">{entry.name}</span>
|
||||
@@ -982,7 +1093,7 @@ function StrmDirBrowserDialog({
|
||||
className="rounded-lg border border-brand-200 bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-500 hover:bg-brand-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect(entry.id)
|
||||
onSelect(entry.id, entry.name, chainFullPath([...dirChainRef.current, { id: entry.id, name: entry.name }]))
|
||||
}}
|
||||
>
|
||||
选择此目录
|
||||
@@ -998,7 +1109,12 @@ function StrmDirBrowserDialog({
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-3">
|
||||
<span className="text-xs text-sand-500">双击进入目录,点击「选择此目录」使用该目录 ID / 路径</span>
|
||||
<button type="button" className="neon-button" onClick={() => onSelect(dir)} disabled={loading}>
|
||||
<button
|
||||
type="button"
|
||||
className="neon-button"
|
||||
onClick={() => onSelect(dir, dirChain[dirChain.length - 1]?.name, chainFullPath(dirChain))}
|
||||
disabled={loading}
|
||||
>
|
||||
选择当前目录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -163,6 +163,22 @@ export function StrmManagePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openAddPathDialog = async () => {
|
||||
try {
|
||||
const settings = await strmAPI.getSettings()
|
||||
const baseUrl = settings?.['strm.base_url']?.trim()
|
||||
if (!baseUrl) {
|
||||
toast.error('未填写strm地址')
|
||||
setDialog('settings')
|
||||
return
|
||||
}
|
||||
setEditingPath(null)
|
||||
setDialog('path')
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -186,7 +202,7 @@ export function StrmManagePage() {
|
||||
<Settings size={16} />
|
||||
STRM 设置
|
||||
</button>
|
||||
<button type="button" onClick={() => { setEditingPath(null); setDialog('path') }} className="neon-button">
|
||||
<button type="button" onClick={openAddPathDialog} className="neon-button">
|
||||
<FolderPlus size={16} />
|
||||
添加同步目录
|
||||
</button>
|
||||
@@ -210,7 +226,7 @@ export function StrmManagePage() {
|
||||
<SyncPathSection
|
||||
paths={paths}
|
||||
actingPath={actingPath}
|
||||
onAdd={() => { setEditingPath(null); setDialog('path') }}
|
||||
onAdd={openAddPathDialog}
|
||||
onEdit={(path) => { setEditingPath(path); setDialog('path') }}
|
||||
onDelete={deletePath}
|
||||
onStart={startSync}
|
||||
@@ -236,6 +252,7 @@ export function StrmManagePage() {
|
||||
existing={editingPath}
|
||||
onClose={() => setDialog(null)}
|
||||
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
|
||||
onOpenSettings={() => setDialog('settings')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -442,7 +459,12 @@ function SyncPathSection({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-ink-50">
|
||||
<span className="max-w-[45%] truncate">{path.remote_path || '(根目录)'}</span>
|
||||
<span
|
||||
className="max-w-[45%] truncate"
|
||||
title={path.remote_display_path || path.remote_path || '(根目录)'}
|
||||
>
|
||||
{path.remote_display_path || path.remote_path || '(根目录)'}
|
||||
</span>
|
||||
<span className="text-sand-500">→</span>
|
||||
<span className="max-w-[45%] truncate">{path.local_path}</span>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface StrmSyncPath {
|
||||
account_id: string
|
||||
provider: StrmProvider
|
||||
remote_path: string
|
||||
remote_display_path?: string
|
||||
local_path: string
|
||||
strm_base_url: string
|
||||
video_ext: string
|
||||
@@ -86,6 +87,7 @@ export interface StrmSyncPathInput {
|
||||
account_id?: string
|
||||
provider: StrmProvider
|
||||
remote_path: string
|
||||
remote_display_path?: string
|
||||
local_path: string
|
||||
strm_base_url?: string
|
||||
video_ext?: string
|
||||
|
||||
@@ -6,6 +6,23 @@ export function lastPathSegment(path: string): string {
|
||||
return parts[parts.length - 1] ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 取远端末级目录的展示/拼接名。115 等以 ID 作为 remote_path 的网盘
|
||||
* 传入浏览时拿到的目录名 tailName,避免把数字 ID 拼进本地路径。
|
||||
*/
|
||||
export function resolveRemoteTailName(remotePath: string, tailName?: string): string {
|
||||
const name = tailName?.trim()
|
||||
if (name) return name
|
||||
return lastPathSegment(remotePath)
|
||||
}
|
||||
|
||||
/** 优先取展示路径(115 浏览/反查得到)的末级目录名,否则取远端路径末段。 */
|
||||
export function remoteTailNameOf(remotePath: string, displayPath?: string): string {
|
||||
const trimmed = displayPath?.trim()
|
||||
if (trimmed) return lastPathSegment(trimmed)
|
||||
return lastPathSegment(remotePath)
|
||||
}
|
||||
|
||||
function pathSeparator(path: string): '/' | '\\' {
|
||||
return path.includes('\\') ? '\\' : '/'
|
||||
}
|
||||
@@ -30,13 +47,14 @@ function stripRemoteTail(localPath: string, prevRemoteTail: string): string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。 */
|
||||
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。tailName 为浏览时拿到的目录名。 */
|
||||
export function syncLocalPathWithRemote(
|
||||
localPath: string,
|
||||
remotePath: string,
|
||||
prevRemoteTail = '',
|
||||
tailName?: string,
|
||||
): { localPath: string; remoteTail: string } {
|
||||
const remoteTail = lastPathSegment(remotePath)
|
||||
const remoteTail = resolveRemoteTailName(remotePath, tailName)
|
||||
const base = stripRemoteTail(localPath, prevRemoteTail)
|
||||
if (!remoteTail) {
|
||||
return { localPath: base || localPath.trim(), remoteTail: '' }
|
||||
|
||||
Reference in New Issue
Block a user