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)} />
覆盖已存在
+