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