diff --git a/internal/handler/strm.go b/internal/handler/strm.go
index 8713abe..bcac96f 100644
--- a/internal/handler/strm.go
+++ b/internal/handler/strm.go
@@ -123,6 +123,7 @@ type generateSTRMTreeReq struct {
Cleanup bool `json:"cleanup"`
DryRun bool `json:"dry_run"`
BatchLimit int `json:"batch_limit"`
+ RecognizeRename bool `json:"recognize_rename"`
TransferSubtitles bool `json:"transfer_subtitles"`
MissingOnly bool `json:"missing_only"`
RefreshLibrary bool `json:"refresh_library"`
@@ -226,6 +227,7 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc {
Cleanup: req.Cleanup,
DryRun: req.DryRun,
BatchLimit: req.BatchLimit,
+ RecognizeRename: req.RecognizeRename,
TransferSubtitles: req.TransferSubtitles,
MissingOnly: req.MissingOnly,
})
diff --git a/internal/service/strm_tree_import.go b/internal/service/strm_tree_import.go
index 093c27e..c285c83 100644
--- a/internal/service/strm_tree_import.go
+++ b/internal/service/strm_tree_import.go
@@ -21,6 +21,7 @@ type GenerateSTRMTreeOptions struct {
Cleanup bool `json:"cleanup"`
DryRun bool `json:"dry_run"`
BatchLimit int `json:"batch_limit,omitempty"`
+ RecognizeRename bool `json:"recognize_rename,omitempty"`
TransferSubtitles bool `json:"transfer_subtitles,omitempty"`
MissingOnly bool `json:"missing_only,omitempty"`
}
@@ -85,7 +86,7 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre
return result, ctx.Err()
default:
}
- item := generateTreeSTRMItem(outputDir, source, opts, existingRefs)
+ item := s.generateTreeSTRMItem(ctx, outputDir, source, opts, existingRefs)
if item.FilePath != "" && item.Action != "error" {
expectedFiles[filepath.Clean(item.FilePath)] = struct{}{}
}
@@ -117,9 +118,15 @@ func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool {
return result.Generated+result.Updated+result.Previewed >= limit
}
-func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem {
+func (s *STRMService) generateTreeSTRMItem(ctx context.Context, outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem {
relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
relPath, err := strmTreeOutputRelativePath(relSource)
+ if opts.RecognizeRename && source.Kind != strmTreeSourceKindSubtitle {
+ if renamed, renameErr := s.strmTreeRecognizedOutputRelativePath(ctx, source.Path, opts.SourceRoot); renameErr == nil && renamed != "" {
+ relPath = renamed
+ err = nil
+ }
+ }
if source.Kind == strmTreeSourceKindSubtitle {
relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource)
}
diff --git a/internal/service/strm_tree_import_test.go b/internal/service/strm_tree_import_test.go
index 7b21b1d..5125ae7 100644
--- a/internal/service/strm_tree_import_test.go
+++ b/internal/service/strm_tree_import_test.go
@@ -44,6 +44,36 @@ func TestGenerateSTRMFromTreePaths(t *testing.T) {
}
}
+func TestGenerateSTRMFromTreeRecognizeRenameOutputPaths(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{"/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv", "/电影/Dune.Part.Two.2024.2160p.WEB-DL.mkv"},
+ SourceRoot: "/电视剧",
+ OutputDir: outDir,
+ RecognizeRename: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Generated != 2 || len(res.Errors) != 0 {
+ t.Fatalf("result = %#v, want two generated renamed STRM files", res)
+ }
+ episode := readSTRM(t, filepath.Join(outDir, "南部档案", "Season 01", "南部档案 S01E02.strm"))
+ if !strings.Contains(episode, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv") {
+ t.Fatalf("episode strm URL = %q, want original cloud ref preserved", episode)
+ }
+ movie := readSTRM(t, filepath.Join(outDir, "Dune Part Two (2024)", "Dune Part Two (2024).strm"))
+ if !strings.Contains(movie, "ref=%2F%E7%94%B5%E5%BD%B1%2FDune.Part.Two.2024.2160p.WEB-DL.mkv") {
+ t.Fatalf("movie strm URL = %q, want original cloud ref preserved", movie)
+ }
+ if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.strm")); !os.IsNotExist(err) {
+ t.Fatalf("recognize rename should not leave original episode output path, stat err=%v", err)
+ }
+}
+
func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
diff --git a/internal/service/strm_tree_paths.go b/internal/service/strm_tree_paths.go
index 29a1939..1df4e5c 100644
--- a/internal/service/strm_tree_paths.go
+++ b/internal/service/strm_tree_paths.go
@@ -1,10 +1,12 @@
package service
import (
+ "context"
"errors"
"fmt"
"path"
"path/filepath"
+ "strconv"
"strings"
)
@@ -32,6 +34,70 @@ func strmTreeOutputRelativePath(source string) (string, error) {
return strmTreeOutputRelativePathWithLinkExtension(source, videoExtensions, ".strm", false)
}
+func (s *STRMService) strmTreeRecognizedOutputRelativePath(ctx context.Context, source, sourceRoot string) (string, error) {
+ source = normalizeSTRMTreeSource(source)
+ season, episode := ParseEpisode(source)
+ title, year := s.strmTreeCleanQuery(ctx, source)
+ if season > 0 || episode > 0 {
+ show := s.strmTreeRecognizedSeriesTitle(ctx, source, sourceRoot, title)
+ if show == "" {
+ return "", errors.New("empty recognized series title")
+ }
+ if season <= 0 {
+ season = 1
+ }
+ if episode <= 0 {
+ return "", errors.New("missing recognized episode number")
+ }
+ name := fmt.Sprintf("%s S%02dE%02d.strm", show, season, episode)
+ return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)), nil
+ }
+ if title == "" {
+ return "", errors.New("empty recognized movie title")
+ }
+ movie := sanitizeFilename(titleCaseWords(title))
+ if movie == "" {
+ return "", errors.New("empty recognized movie filename")
+ }
+ folder := movie
+ if year > 0 && !strings.Contains(folder, strconv.Itoa(year)) {
+ folder = fmt.Sprintf("%s (%d)", movie, year)
+ }
+ return filepath.Join(sanitizeFilename(folder), sanitizeFilename(folder)+".strm"), nil
+}
+
+func (s *STRMService) strmTreeRecognizedSeriesTitle(ctx context.Context, source, sourceRoot, fallback string) string {
+ rel := strmTreeRelativeSource(source, sourceRoot)
+ parts := strings.Split(strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), "/")
+ if len(parts) >= 2 {
+ dir := parts[len(parts)-2]
+ if strings.TrimSpace(dir) != "" && !seriesSeasonDirRE.MatchString(dir) {
+ if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" {
+ return sanitizeFilename(titleCaseWords(title))
+ }
+ return sanitizeFilename(strings.TrimSpace(dir))
+ }
+ }
+ if len(parts) >= 3 {
+ dir := parts[len(parts)-3]
+ if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" {
+ return sanitizeFilename(titleCaseWords(title))
+ }
+ return sanitizeFilename(strings.TrimSpace(dir))
+ }
+ if fallback != "" {
+ return sanitizeFilename(titleCaseWords(fallback))
+ }
+ return ""
+}
+
+func (s *STRMService) strmTreeCleanQuery(ctx context.Context, raw string) (string, int) {
+ if s == nil {
+ return CleanQuery(raw)
+ }
+ return CleanQueryWithRecognition(ctx, s.repo, raw)
+}
+
func strmTreeOutputSubtitleLinkRelativePath(source string) (string, error) {
return strmTreeOutputRelativePathWithLinkExtension(source, strmTreeSubtitleExtensions, ".strm", true)
}
diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts
index 19d243b..a84acff 100644
--- a/web/src/api/strm.ts
+++ b/web/src/api/strm.ts
@@ -49,6 +49,7 @@ export type GenerateSTRMTreeInput = {
cleanup?: boolean
dry_run?: boolean
batch_limit?: number
+ recognize_rename?: boolean
transfer_subtitles?: boolean
missing_only?: boolean
refresh_library?: boolean
diff --git a/web/src/pages/StrmTreeGenerateSection.tsx b/web/src/pages/StrmTreeGenerateSection.tsx
index baa7b78..1aea948 100644
--- a/web/src/pages/StrmTreeGenerateSection.tsx
+++ b/web/src/pages/StrmTreeGenerateSection.tsx
@@ -46,6 +46,7 @@ export function StrmTreeGenerateSection({
overwrite,
pathsText,
provider,
+ recognizeRename,
refreshLibrary,
result,
runningMode,
@@ -59,6 +60,7 @@ export function StrmTreeGenerateSection({
setOverwrite,
setPathsText,
setProvider,
+ setRecognizeRename,
setRefreshLibrary,
setScrapeAfter,
setSourceRoot,
@@ -193,6 +195,10 @@ export function StrmTreeGenerateSection({
setOverwrite(e.target.checked)} />
覆盖已存在
+