diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 4bc9a00..c35cffc 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -118,6 +118,7 @@ type generateSTRMTreeReq struct { BaseURL string `json:"base_url"` Overwrite bool `json:"overwrite"` Cleanup bool `json:"cleanup"` + DryRun bool `json:"dry_run"` } func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { @@ -189,6 +190,7 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc { BaseURL: baseURL, Overwrite: req.Overwrite, Cleanup: req.Cleanup, + DryRun: req.DryRun, }) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/internal/service/strm_generate.go b/internal/service/strm_generate.go index 9b64704..012dd37 100644 --- a/internal/service/strm_generate.go +++ b/internal/service/strm_generate.go @@ -31,6 +31,7 @@ type GenerateSTRMResult struct { Updated int `json:"updated"` Skipped int `json:"skipped"` Cleaned int `json:"cleaned"` + Previewed int `json:"previewed,omitempty"` Errors []string `json:"errors,omitempty"` Items []GenerateSTRMItem `json:"items,omitempty"` } @@ -269,6 +270,8 @@ func (r *GenerateSTRMResult) addItem(item GenerateSTRMItem) { r.Updated++ case "skipped": r.Skipped++ + case "preview": + r.Previewed++ case "error": r.Errors = append(r.Errors, fmt.Sprintf("%s: %s", item.Title, item.Reason)) } @@ -285,6 +288,7 @@ func (r *GenerateSTRMResult) merge(part *GenerateSTRMResult) { r.Updated += part.Updated r.Skipped += part.Skipped r.Cleaned += part.Cleaned + r.Previewed += part.Previewed r.Errors = append(r.Errors, part.Errors...) r.Items = append(r.Items, part.Items...) } diff --git a/internal/service/strm_tree_import.go b/internal/service/strm_tree_import.go index 62ce36d..a4cc138 100644 --- a/internal/service/strm_tree_import.go +++ b/internal/service/strm_tree_import.go @@ -19,6 +19,7 @@ type GenerateSTRMTreeOptions struct { BaseURL string `json:"base_url,omitempty"` Overwrite bool `json:"overwrite"` Cleanup bool `json:"cleanup"` + DryRun bool `json:"dry_run"` } type strmTreeSource struct { @@ -36,8 +37,10 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre if outputDir == "" || outputDir == "." { return nil, errors.New("output_dir required") } - if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players. - return nil, err + if !opts.DryRun { + if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players. + return nil, err + } } result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir} expectedFiles := make(map[string]struct{}) @@ -53,7 +56,7 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre } result.addItem(item) } - if opts.Cleanup && len(expectedFiles) > 0 { + if opts.Cleanup && !opts.DryRun && len(expectedFiles) > 0 { cleanupDir := outputDir if prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix); err == nil && prefix != "" { cleanupDir = filepath.Join(outputDir, prefix) @@ -94,6 +97,11 @@ func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts Generate if _, err := os.Stat(filePath); err == nil { action = "updated" } + if opts.DryRun { + item.Action = "preview" + item.Reason = action + return item + } if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players. item.Action = "error" item.Reason = err.Error() diff --git a/internal/service/strm_tree_import_test.go b/internal/service/strm_tree_import_test.go index 5e63436..5a8738b 100644 --- a/internal/service/strm_tree_import_test.go +++ b/internal/service/strm_tree_import_test.go @@ -81,3 +81,59 @@ func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) { t.Fatalf("iso source should stay ignored by tree generator, stat err=%v", err) } } + +func TestGenerateSTRMFromTreeDryRunDoesNotWriteOrCleanup(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "strm") + stale := filepath.Join(outDir, "Movies", "Old.Movie.strm") + if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stale, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + svc := NewSTRMService(zap.NewNop(), nil, nil) + + res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ + Provider: "openlist", + Paths: []string{"/Movies/New.Movie.2026.mkv"}, + OutputDir: outDir, + Cleanup: true, + DryRun: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Previewed != 1 || res.Generated != 0 || res.Updated != 0 || res.Cleaned != 0 || len(res.Errors) != 0 { + t.Fatalf("result = %#v, want one preview and no writes", res) + } + if len(res.Items) != 1 || res.Items[0].Action != "preview" || res.Items[0].Reason != "generated" { + t.Fatalf("preview item = %#v, want generated preview", res.Items) + } + if _, err := os.Stat(filepath.Join(outDir, "Movies", "New.Movie.2026.strm")); !os.IsNotExist(err) { + t.Fatalf("dry run should not write new strm, stat err=%v", err) + } + if got := readSTRM(t, stale); got != "old" { + t.Fatalf("dry run cleanup touched stale file: %q", got) + } +} + +func TestGenerateSTRMFromTreeDryRunDoesNotCreateOutputDir(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "missing-strm") + svc := NewSTRMService(zap.NewNop(), nil, nil) + + res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ + Provider: "openlist", + Paths: []string{"/Movies/New.Movie.2026.mkv"}, + OutputDir: outDir, + DryRun: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Previewed != 1 { + t.Fatalf("previewed = %d, want 1", res.Previewed) + } + if _, err := os.Stat(outDir); !os.IsNotExist(err) { + t.Fatalf("dry run should not create output dir, stat err=%v", err) + } +} diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 901f955..449e848 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -17,6 +17,7 @@ export type GenerateSTRMResult = { updated: number skipped: number cleaned: number + previewed?: number errors?: string[] items?: Array<{ media_id: string @@ -38,6 +39,7 @@ export type GenerateSTRMTreeInput = { base_url?: string overwrite?: boolean cleanup?: boolean + dry_run?: boolean } export const strmAPI = { diff --git a/web/src/pages/StrmGenerateSectionParts.tsx b/web/src/pages/StrmGenerateSectionParts.tsx index bc45360..a840758 100644 --- a/web/src/pages/StrmGenerateSectionParts.tsx +++ b/web/src/pages/StrmGenerateSectionParts.tsx @@ -271,6 +271,7 @@ export function StrmGenerateResultPanel({ result }: { result: GenerateSTRMResult 输出目录:{result.output_dir}