From e7d988ebf830b7888c5cf1da71ed86d56e4c3d51 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:41:37 +0800 Subject: [PATCH] feat: transfer STRM tree subtitle links --- internal/handler/strm.go | 46 +++--- internal/service/strm_tree_import.go | 166 ++++++++++++++++------ internal/service/strm_tree_import_test.go | 45 ++++++ internal/service/strm_tree_normalize.go | 45 ++++++ internal/service/strm_tree_paths.go | 49 ++++++- web/src/api/strm.ts | 1 + web/src/pages/StrmTreeGenerateSection.tsx | 6 + web/src/pages/useStrmTreeGenerateForm.ts | 4 + 8 files changed, 291 insertions(+), 71 deletions(-) diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 1f842a7..1fe1796 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -109,17 +109,18 @@ type generateSTRMReq struct { } type generateSTRMTreeReq struct { - Provider string `json:"provider"` - TreeText string `json:"tree_text"` - Paths []string `json:"paths"` - SourceRoot string `json:"source_root"` - OutputPrefix string `json:"output_prefix"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url"` - Overwrite bool `json:"overwrite"` - Cleanup bool `json:"cleanup"` - DryRun bool `json:"dry_run"` - BatchLimit int `json:"batch_limit"` + Provider string `json:"provider"` + TreeText string `json:"tree_text"` + Paths []string `json:"paths"` + SourceRoot string `json:"source_root"` + OutputPrefix string `json:"output_prefix"` + OutputDir string `json:"output_dir"` + BaseURL string `json:"base_url"` + Overwrite bool `json:"overwrite"` + Cleanup bool `json:"cleanup"` + DryRun bool `json:"dry_run"` + BatchLimit int `json:"batch_limit"` + TransferSubtitles bool `json:"transfer_subtitles"` } func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { @@ -182,17 +183,18 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc { baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") } res, err := strmSvc.GenerateFromTree(c.Request.Context(), service.GenerateSTRMTreeOptions{ - Provider: req.Provider, - TreeText: req.TreeText, - Paths: req.Paths, - SourceRoot: req.SourceRoot, - OutputPrefix: req.OutputPrefix, - OutputDir: req.OutputDir, - BaseURL: baseURL, - Overwrite: req.Overwrite, - Cleanup: req.Cleanup, - DryRun: req.DryRun, - BatchLimit: req.BatchLimit, + Provider: req.Provider, + TreeText: req.TreeText, + Paths: req.Paths, + SourceRoot: req.SourceRoot, + OutputPrefix: req.OutputPrefix, + OutputDir: req.OutputDir, + BaseURL: baseURL, + Overwrite: req.Overwrite, + Cleanup: req.Cleanup, + DryRun: req.DryRun, + BatchLimit: req.BatchLimit, + TransferSubtitles: req.TransferSubtitles, }) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/internal/service/strm_tree_import.go b/internal/service/strm_tree_import.go index 4aed870..3144368 100644 --- a/internal/service/strm_tree_import.go +++ b/internal/service/strm_tree_import.go @@ -10,31 +10,49 @@ import ( ) type GenerateSTRMTreeOptions struct { - Provider string `json:"provider"` - TreeText string `json:"tree_text,omitempty"` - Paths []string `json:"paths,omitempty"` - SourceRoot string `json:"source_root,omitempty"` - OutputPrefix string `json:"output_prefix,omitempty"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url,omitempty"` - Overwrite bool `json:"overwrite"` - Cleanup bool `json:"cleanup"` - DryRun bool `json:"dry_run"` - BatchLimit int `json:"batch_limit,omitempty"` + Provider string `json:"provider"` + TreeText string `json:"tree_text,omitempty"` + Paths []string `json:"paths,omitempty"` + SourceRoot string `json:"source_root,omitempty"` + OutputPrefix string `json:"output_prefix,omitempty"` + OutputDir string `json:"output_dir"` + BaseURL string `json:"base_url,omitempty"` + Overwrite bool `json:"overwrite"` + Cleanup bool `json:"cleanup"` + DryRun bool `json:"dry_run"` + BatchLimit int `json:"batch_limit,omitempty"` + TransferSubtitles bool `json:"transfer_subtitles,omitempty"` } type strmTreeSource struct { Provider string Path string RefPath string + Kind string } +const ( + strmTreeSourceKindVideo = "video" + strmTreeSourceKindSubtitle = "subtitle" +) + type strmTreeSourceCollection struct { sources []strmTreeSource ignored []string ignoredCount int } +type strmTreeSourceCollector struct { + fallbackProvider string + transferSubtitles bool + sources []strmTreeSource + subtitles []strmTreeSource + ignored []string + ignoredCount int + seen map[string]struct{} + seenIgnored map[string]struct{} +} + func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) { provider := normalizeSTRMTreeProvider(opts.Provider) if provider == "" { @@ -97,6 +115,9 @@ func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool { func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions) GenerateSTRMItem { relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot) relPath, err := strmTreeOutputRelativePath(relSource) + if source.Kind == strmTreeSourceKindSubtitle { + relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource) + } item := GenerateSTRMItem{Title: strings.TrimSuffix(path.Base(source.Path), path.Ext(source.Path))} if err != nil { item.Action = "error" @@ -141,49 +162,100 @@ func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts Generate } func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection { - fallbackProvider := normalizeSTRMTreeProvider(opts.Provider) - out := make([]strmTreeSource, 0, len(opts.Paths)) - seen := map[string]struct{}{} - ignored := make([]string, 0) - ignoredCount := 0 - seenIgnored := map[string]struct{}{} - addIgnored := func(value string) { - if ignoredPath, ok := strmTreeIgnoredFileLikeSource(value); ok { - key := strings.ToLower(ignoredPath) - if _, exists := seenIgnored[key]; exists { - return - } - seenIgnored[key] = struct{}{} - ignoredCount++ - if len(ignored) < strmTreeIgnoredItemSampleLimit { - ignored = append(ignored, ignoredPath) - } - } - } - add := func(value string) { - source := normalizeSTRMTreeSourceWithProvider(value, fallbackProvider) - if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) { - addIgnored(value) - return - } - key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath()) - if _, ok := seen[key]; ok { - return - } - seen[key] = struct{}{} - out = append(out, source) - } + collector := newSTRMTreeSourceCollector(opts) for _, value := range opts.Paths { - add(value) + collector.add(value) } treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText) for _, value := range treeSources { - add(value) + collector.add(value) } for _, value := range treeIgnored { - addIgnored(value) + collector.addIgnoredOrSubtitle(value) } - return strmTreeSourceCollection{sources: out, ignored: ignored, ignoredCount: ignoredCount} + collector.finalizeSubtitles() + return collector.collection() +} + +func newSTRMTreeSourceCollector(opts GenerateSTRMTreeOptions) *strmTreeSourceCollector { + return &strmTreeSourceCollector{ + fallbackProvider: normalizeSTRMTreeProvider(opts.Provider), + transferSubtitles: opts.TransferSubtitles, + sources: make([]strmTreeSource, 0, len(opts.Paths)), + subtitles: make([]strmTreeSource, 0), + ignored: make([]string, 0), + seen: map[string]struct{}{}, + seenIgnored: map[string]struct{}{}, + } +} + +func (c *strmTreeSourceCollector) add(value string) { + source := normalizeSTRMTreeSourceWithProvider(value, c.fallbackProvider) + if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) { + c.addIgnoredOrSubtitle(value) + return + } + source.Kind = strmTreeSourceKindVideo + c.addSource(source) +} + +func (c *strmTreeSourceCollector) addIgnoredOrSubtitle(value string) { + if c.transferSubtitles && c.addSubtitleCandidate(value) { + return + } + c.addIgnored(value) +} + +func (c *strmTreeSourceCollector) addSource(source strmTreeSource) { + if source.Kind == "" { + source.Kind = strmTreeSourceKindVideo + } + key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Kind) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath()) + if _, ok := c.seen[key]; ok { + return + } + c.seen[key] = struct{}{} + c.sources = append(c.sources, source) +} + +func (c *strmTreeSourceCollector) addSubtitleCandidate(value string) bool { + source := normalizeSTRMTreeSubtitleSourceWithProvider(value, c.fallbackProvider) + if source.Provider == "" || source.Path == "" { + return false + } + c.subtitles = append(c.subtitles, source) + return true +} + +func (c *strmTreeSourceCollector) addIgnored(value string) { + if ignoredPath, ok := strmTreeIgnoredFileLikeSource(value); ok { + key := strings.ToLower(ignoredPath) + if _, exists := c.seenIgnored[key]; exists { + return + } + c.seenIgnored[key] = struct{}{} + c.ignoredCount++ + if len(c.ignored) < strmTreeIgnoredItemSampleLimit { + c.ignored = append(c.ignored, ignoredPath) + } + } +} + +func (c *strmTreeSourceCollector) finalizeSubtitles() { + if !c.transferSubtitles { + return + } + for _, source := range c.subtitles { + if strmTreeSubtitleMatchesVideo(source, c.sources) { + c.addSource(source) + continue + } + c.addIgnored(source.Path) + } +} + +func (c *strmTreeSourceCollector) collection() strmTreeSourceCollection { + return strmTreeSourceCollection{sources: c.sources, ignored: c.ignored, ignoredCount: c.ignoredCount} } func (s strmTreeSource) cloudRefPath() string { diff --git a/internal/service/strm_tree_import_test.go b/internal/service/strm_tree_import_test.go index 5dc4e8b..7b21b1d 100644 --- a/internal/service/strm_tree_import_test.go +++ b/internal/service/strm_tree_import_test.go @@ -121,6 +121,51 @@ func TestGenerateSTRMFromTreeReportsIgnoredFileLikeRows(t *testing.T) { } } +func TestGenerateSTRMFromTreeTransfersMatchingSubtitleLinks(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "strm") + svc := NewSTRMService(zap.NewNop(), nil, nil) + + res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ + Provider: "openlist", + Paths: []string{ + "/Movies/A.mkv", + "/Movies/A.zh.srt", + "/Movies/Orphan.srt", + }, + TreeText: strings.Join([]string{ + "电视剧", + "└── Show.Name.2026", + " ├── Show.S01E01.mp4", + " ├── Show.S01E01.ass", + " └── Show.S01E02.srt", + }, "\n"), + OutputDir: outDir, + BaseURL: "https://media.example.com", + TransferSubtitles: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Generated != 4 || res.Ignored != 2 || len(res.Errors) != 0 { + t.Fatalf("result = %#v, want two video links, two matching subtitle links, and two orphan subtitles ignored", res) + } + movieSubtitle := readSTRM(t, filepath.Join(outDir, "Movies", "A.zh.srt.strm")) + if !strings.HasPrefix(movieSubtitle, "https://media.example.com/api/cloud/play/openlist?") || + !strings.Contains(movieSubtitle, "ref=%2FMovies%2FA.zh.srt") { + t.Fatalf("subtitle link = %q, want cloud play URL for subtitle source", movieSubtitle) + } + showSubtitle := readSTRM(t, filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E01.ass.strm")) + if !strings.Contains(showSubtitle, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2FShow.Name.2026%2FShow.S01E01.ass") { + t.Fatalf("tree subtitle link = %q, want full tree subtitle ref", showSubtitle) + } + if _, err := os.Stat(filepath.Join(outDir, "Movies", "Orphan.srt.strm")); !os.IsNotExist(err) { + t.Fatalf("orphan subtitle should not generate a link, stat err=%v", err) + } + if _, err := os.Stat(filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E02.srt.strm")); !os.IsNotExist(err) { + t.Fatalf("subtitle without matching episode should not generate a link, stat err=%v", err) + } +} + func TestGenerateSTRMFromTreeLimitsIgnoredItemSamples(t *testing.T) { outDir := filepath.Join(t.TempDir(), "strm") paths := make([]string, 0, 25) diff --git a/internal/service/strm_tree_normalize.go b/internal/service/strm_tree_normalize.go index c91b64f..97af2cb 100644 --- a/internal/service/strm_tree_normalize.go +++ b/internal/service/strm_tree_normalize.go @@ -31,6 +31,15 @@ var strmTreeKnownNonVideoExtensions = map[string]struct{}{ ".webp": {}, } +var strmTreeSubtitleExtensions = map[string]struct{}{ + ".ass": {}, + ".idx": {}, + ".srt": {}, + ".ssa": {}, + ".sub": {}, + ".vtt": {}, +} + func normalizeSTRMTreeProvider(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "115", "115pan", "pan115", "cloud115": @@ -109,6 +118,30 @@ func normalizeSTRMTreeSourceWithProvider(raw, fallbackProvider string) strmTreeS return strmTreeSource{Provider: provider, Path: source} } +func normalizeSTRMTreeSubtitleSourceWithProvider(raw, fallbackProvider string) strmTreeSource { + provider := normalizeSTRMTreeProvider(fallbackProvider) + value := strings.TrimSpace(strings.Trim(raw, `"'`)) + if value == "" { + return strmTreeSource{} + } + if info, ok := ParseCloudLibraryMount(value); ok { + source, sourceOK := strmTreeSubtitleFileLikeSource(info.DisplayDir) + if !sourceOK { + return strmTreeSource{} + } + ref, refOK := strmTreeSubtitleFileLikeSource(info.ScanDir) + if !refOK { + ref = source + } + return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref, Kind: strmTreeSourceKindSubtitle} + } + source, ok := strmTreeSubtitleFileLikeSource(value) + if !ok { + return strmTreeSource{} + } + return strmTreeSource{Provider: provider, Path: source, Kind: strmTreeSourceKindSubtitle} +} + func strmTreeCloudPlaySourceFromURL(parsed *url.URL) (string, string) { if parsed == nil { return "", "" @@ -241,6 +274,18 @@ func strmTreeIgnoredFileLikeSource(raw string) (string, bool) { return "", false } +func strmTreeSubtitleFileLikeSource(raw string) (string, bool) { + source, ok := strmTreeIgnoredFileLikeSource(raw) + if !ok { + return "", false + } + ext := strings.ToLower(path.Ext(source)) + if _, ok := strmTreeSubtitleExtensions[ext]; !ok { + return "", false + } + return source, true +} + func cleanSTRMTreePathDecorations(value string) string { if value == "" { return "" diff --git a/internal/service/strm_tree_paths.go b/internal/service/strm_tree_paths.go index ced42eb..29a1939 100644 --- a/internal/service/strm_tree_paths.go +++ b/internal/service/strm_tree_paths.go @@ -29,6 +29,14 @@ func strmTreeCloudRef(source, sourceRoot string) string { } func strmTreeOutputRelativePath(source string) (string, error) { + return strmTreeOutputRelativePathWithLinkExtension(source, videoExtensions, ".strm", false) +} + +func strmTreeOutputSubtitleLinkRelativePath(source string) (string, error) { + return strmTreeOutputRelativePathWithLinkExtension(source, strmTreeSubtitleExtensions, ".strm", true) +} + +func strmTreeOutputRelativePathWithLinkExtension(source string, allowedExtensions map[string]struct{}, linkExtension string, appendLinkExtension bool) (string, error) { parts := strings.Split(strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/"), "/") if len(parts) == 0 { return "", errors.New("empty source path") @@ -41,10 +49,16 @@ func strmTreeOutputRelativePath(source string) (string, error) { } if i == len(parts)-1 { ext := strings.ToLower(path.Ext(part)) - if _, ok := videoExtensions[ext]; !ok { + if _, ok := allowedExtensions[ext]; !ok { return "", fmt.Errorf("unsupported media extension %q", ext) } - part = strings.TrimSuffix(part, path.Ext(part)) + ".strm" + if linkExtension != "" { + if appendLinkExtension { + part += linkExtension + } else { + part = strings.TrimSuffix(part, path.Ext(part)) + linkExtension + } + } } safe := sanitizeFilename(part) if safe == "" { @@ -55,6 +69,37 @@ func strmTreeOutputRelativePath(source string) (string, error) { return filepath.Join(out...), nil } +func strmTreeSubtitleMatchesVideo(subtitle strmTreeSource, videos []strmTreeSource) bool { + subDir, subBase := strmTreeDirAndBase(subtitle.Path) + if subBase == "" { + return false + } + for _, video := range videos { + if video.Kind != "" && video.Kind != strmTreeSourceKindVideo { + continue + } + if !strings.EqualFold(subtitle.Provider, video.Provider) { + continue + } + videoDir, videoBase := strmTreeDirAndBase(video.Path) + if !strings.EqualFold(subDir, videoDir) || videoBase == "" { + continue + } + if strings.EqualFold(subBase, videoBase) || strings.HasPrefix(strings.ToLower(subBase), strings.ToLower(videoBase)+".") { + return true + } + } + return false +} + +func strmTreeDirAndBase(source string) (string, string) { + source = normalizeSTRMTreeSource(source) + dir := path.Dir(source) + name := path.Base(source) + base := strings.TrimSuffix(name, path.Ext(name)) + return strings.ToLower(strings.Trim(dir, "/")), strings.ToLower(base) +} + func strmTreeOutputPrefixPath(prefix string) (string, error) { parts := strings.Split(strings.Trim(strings.ReplaceAll(prefix, "\\", "/"), "/"), "/") out := make([]string, 0, len(parts)) diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index e07dc7e..1182cfb 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -46,6 +46,7 @@ export type GenerateSTRMTreeInput = { cleanup?: boolean dry_run?: boolean batch_limit?: number + transfer_subtitles?: boolean } export const strmAPI = { diff --git a/web/src/pages/StrmTreeGenerateSection.tsx b/web/src/pages/StrmTreeGenerateSection.tsx index 25cf2b5..f08a68b 100644 --- a/web/src/pages/StrmTreeGenerateSection.tsx +++ b/web/src/pages/StrmTreeGenerateSection.tsx @@ -52,7 +52,9 @@ export function StrmTreeGenerateSection({ setProvider, setSourceRoot, setTreeText, + setTransferSubtitles, sourceRoot, + transferSubtitles, treeText, }: StrmTreeGenerateSectionProps) { const batchLimitEnabled = Number.parseInt(batchLimit, 10) > 0 @@ -179,6 +181,10 @@ export function StrmTreeGenerateSection({ setOverwrite(e.target.checked)} /> 覆盖已存在 +