diff --git a/internal/handler/strm.go b/internal/handler/strm.go index c35cffc..1f842a7 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -119,6 +119,7 @@ type generateSTRMTreeReq struct { Overwrite bool `json:"overwrite"` Cleanup bool `json:"cleanup"` DryRun bool `json:"dry_run"` + BatchLimit int `json:"batch_limit"` } func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { @@ -191,6 +192,7 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc { Overwrite: req.Overwrite, Cleanup: req.Cleanup, DryRun: req.DryRun, + BatchLimit: req.BatchLimit, }) 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 a4cc138..957cc75 100644 --- a/internal/service/strm_tree_import.go +++ b/internal/service/strm_tree_import.go @@ -20,6 +20,7 @@ type GenerateSTRMTreeOptions struct { Overwrite bool `json:"overwrite"` Cleanup bool `json:"cleanup"` DryRun bool `json:"dry_run"` + BatchLimit int `json:"batch_limit,omitempty"` } type strmTreeSource struct { @@ -55,8 +56,11 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre expectedFiles[filepath.Clean(item.FilePath)] = struct{}{} } result.addItem(item) + if strmTreeBatchLimitReached(result, opts.BatchLimit) { + break + } } - if opts.Cleanup && !opts.DryRun && len(expectedFiles) > 0 { + if opts.Cleanup && !opts.DryRun && opts.BatchLimit <= 0 && len(expectedFiles) > 0 { cleanupDir := outputDir if prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix); err == nil && prefix != "" { cleanupDir = filepath.Join(outputDir, prefix) @@ -70,6 +74,13 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre return result, nil } +func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool { + if result == nil || limit <= 0 { + return false + } + return result.Generated+result.Updated+result.Previewed >= limit +} + func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions) GenerateSTRMItem { relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot) relPath, err := strmTreeOutputRelativePath(relSource) diff --git a/internal/service/strm_tree_import_test.go b/internal/service/strm_tree_import_test.go index 5a8738b..d576fa1 100644 --- a/internal/service/strm_tree_import_test.go +++ b/internal/service/strm_tree_import_test.go @@ -137,3 +137,65 @@ func TestGenerateSTRMFromTreeDryRunDoesNotCreateOutputDir(t *testing.T) { t.Fatalf("dry run should not create output dir, stat err=%v", err) } } + +func TestGenerateSTRMFromTreeBatchLimitContinuesAfterExistingFiles(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "strm") + svc := NewSTRMService(zap.NewNop(), nil, nil) + opts := GenerateSTRMTreeOptions{ + Provider: "openlist", + Paths: []string{"/Movies/A.mkv", "/Movies/B.mkv", "/Movies/C.mkv"}, + OutputDir: outDir, + BatchLimit: 2, + } + + first, err := svc.GenerateFromTree(t.Context(), opts) + if err != nil { + t.Fatal(err) + } + if first.Generated != 2 || first.Skipped != 0 { + t.Fatalf("first batch = %#v, want two generated", first) + } + if _, err := os.Stat(filepath.Join(outDir, "Movies", "C.strm")); !os.IsNotExist(err) { + t.Fatalf("first batch should not write third item, stat err=%v", err) + } + + second, err := svc.GenerateFromTree(t.Context(), opts) + if err != nil { + t.Fatal(err) + } + if second.Generated != 1 || second.Skipped != 2 { + t.Fatalf("second batch = %#v, want two existing skips then next generated", second) + } + if got := readSTRM(t, filepath.Join(outDir, "Movies", "C.strm")); !strings.Contains(got, "C.mkv") { + t.Fatalf("second batch C.strm = %q, want generated third item", got) + } +} + +func TestGenerateSTRMFromTreeBatchLimitSkipsCleanup(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "strm") + stale := filepath.Join(outDir, "Movies", "stale.strm") + if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stale, []byte("keep\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/A.mkv", "/Movies/B.mkv"}, + OutputDir: outDir, + BatchLimit: 1, + Cleanup: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Generated != 1 || res.Cleaned != 0 { + t.Fatalf("batch result = %#v, want one generated and no cleanup", res) + } + if got := readSTRM(t, stale); got != "keep" { + t.Fatalf("batch cleanup should not touch stale file: %q", got) + } +} diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 449e848..d5ec36d 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -40,6 +40,7 @@ export type GenerateSTRMTreeInput = { overwrite?: boolean cleanup?: boolean dry_run?: boolean + batch_limit?: number } export const strmAPI = { diff --git a/web/src/pages/StrmTreeGenerateSection.tsx b/web/src/pages/StrmTreeGenerateSection.tsx index fe67283..25cf2b5 100644 --- a/web/src/pages/StrmTreeGenerateSection.tsx +++ b/web/src/pages/StrmTreeGenerateSection.tsx @@ -29,6 +29,7 @@ const outputPrefixPresets = [ export function StrmTreeGenerateSection({ baseURL, + batchLimit, cleanup, generating, onGenerate, @@ -42,6 +43,7 @@ export function StrmTreeGenerateSection({ result, runningMode, setBaseURL, + setBatchLimit, setCleanup, setOutputDir, setOutputPrefix, @@ -53,6 +55,8 @@ export function StrmTreeGenerateSection({ sourceRoot, treeText, }: StrmTreeGenerateSectionProps) { + const batchLimitEnabled = Number.parseInt(batchLimit, 10) > 0 + return (
@@ -112,11 +116,21 @@ export function StrmTreeGenerateSection({ setOutputPrefix(e.target.value)} /> + setBatchLimit(e.target.value)} + />