From d96782622d8bf53eaabc52836188dd9f8e844d11 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:02:26 +0800 Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/routes_admin.go | 1 + internal/handler/strm.go | 98 +++++++----- internal/model/strm.go | 6 +- internal/repository/strm_repository.go | 45 +++--- internal/service/strm_sync.go | 74 ++++++++- internal/service/strm_sync_test.go | 45 ++++++ web/src/api/strm.ts | 6 + web/src/pages/StrmDialogs.tsx | 206 +++++++++++++++++++------ web/src/pages/StrmManagePage.tsx | 28 +++- web/src/types/strm.ts | 2 + web/src/utils/strmPaths.ts | 22 ++- 11 files changed, 417 insertions(+), 116 deletions(-) diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 262d501..20b21b6 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -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)) diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 06fb741..55842db 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -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), } } diff --git a/internal/model/strm.go b/internal/model/strm.go index 9acc4c2..4799c8d 100644 --- a/internal/model/strm.go +++ b/internal/model/strm.go @@ -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 diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index 0ad8e0a..7b1f2d7 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -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 }) } diff --git a/internal/service/strm_sync.go b/internal/service/strm_sync.go index aed303b..9790582 100644 --- a/internal/service/strm_sync.go +++ b/internal/service/strm_sync.go @@ -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) diff --git a/internal/service/strm_sync_test.go b/internal/service/strm_sync_test.go index 8ca41e8..0d5ac76 100644 --- a/internal/service/strm_sync_test.go +++ b/internal/service/strm_sync_test.go @@ -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) + } +} diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 26ab058..d040b2c 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -80,6 +80,12 @@ export const strmAPI = { .get(`/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('/admin/strm/115/sources').then((r) => r.data), diff --git a/web/src/pages/StrmDialogs.tsx b/web/src/pages/StrmDialogs.tsx index 61d486d..d372b87 100644 --- a/web/src/pages/StrmDialogs.tsx +++ b/web/src/pages/StrmDialogs.tsx @@ -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(() => ({ 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) - 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 = (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('') }} > @@ -707,17 +786,39 @@ export function StrmSyncPathDialog({ isLocal ? '扫描该目录下的视频生成 strm' : form.provider === 'cloud115' - ? '115 目录 ID(可通过浏览选择)' + ? form.remote_path + ? `115 目录 ID:${form.remote_path}(可通过右侧「浏览」选择更换)` + : '可通过右侧「浏览」选择 115 目录,或直接粘贴目录 ID' : '远端路径(可通过浏览选择)' } > 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) + } }} /> @@ -745,8 +846,8 @@ export function StrmSyncPathDialog({ @@ -774,20 +875,6 @@ export function StrmSyncPathDialog({
高级选项
-
- - set('strm_base_url', e.target.value)} /> - - - set('min_video_size_mb', Number(e.target.value))} - /> - -
set('video_ext', e.target.value)} /> @@ -800,6 +887,15 @@ export function StrmSyncPathDialog({
+ + set('min_video_size_mb', Number(e.target.value))} + /> + - - set('cron', e.target.value)} /> -
+ + set('cron', e.target.value)} /> +
set('download_meta', v)} /> set('upload_meta', v)} /> @@ -842,8 +938,8 @@ export function StrmSyncPathDialog({ { - 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([]) const [entries, setEntries] = useState([]) 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 (
- - {crumbs.map((crumb, index) => ( - + {(dirChain.length > 0 ? dirChain.map((item) => item.name) : dir.split('/').filter(Boolean)).map((label, index) => ( + - {crumb} + {label} ))} {dir || '(根目录 / 0)'} @@ -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)} > {entry.name} @@ -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({
双击进入目录,点击「选择此目录」使用该目录 ID / 路径 -
diff --git a/web/src/pages/StrmManagePage.tsx b/web/src/pages/StrmManagePage.tsx index b287724..708409a 100644 --- a/web/src/pages/StrmManagePage.tsx +++ b/web/src/pages/StrmManagePage.tsx @@ -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 (
@@ -186,7 +202,7 @@ export function StrmManagePage() { STRM 设置 - @@ -210,7 +226,7 @@ export function StrmManagePage() { { 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')} /> )}
@@ -442,7 +459,12 @@ function SyncPathSection({
- {path.remote_path || '(根目录)'} + + {path.remote_display_path || path.remote_path || '(根目录)'} + → {path.local_path}
diff --git a/web/src/types/strm.ts b/web/src/types/strm.ts index 804c552..5d46973 100644 --- a/web/src/types/strm.ts +++ b/web/src/types/strm.ts @@ -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 diff --git a/web/src/utils/strmPaths.ts b/web/src/utils/strmPaths.ts index a5b1a24..074b332 100644 --- a/web/src/utils/strmPaths.ts +++ b/web/src/utils/strmPaths.ts @@ -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: '' }