From ccd9756b6f1b80eb50077722577951adf5456144 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:14:16 +0800 Subject: [PATCH] feat: report ignored STRM tree sidecars --- internal/service/strm_generate.go | 11 +++ internal/service/strm_tree_import.go | 38 ++++++++-- internal/service/strm_tree_import_test.go | 60 ++++++++++++++++ internal/service/strm_tree_normalize.go | 82 ++++++++++++++++++---- internal/service/strm_tree_parse.go | 39 ++++++++-- web/src/api/strm.ts | 2 + web/src/pages/StrmGenerateSectionParts.tsx | 8 ++- 7 files changed, 216 insertions(+), 24 deletions(-) diff --git a/internal/service/strm_generate.go b/internal/service/strm_generate.go index 3a693b1..24b2efb 100644 --- a/internal/service/strm_generate.go +++ b/internal/service/strm_generate.go @@ -24,6 +24,8 @@ type GenerateSTRMOptions struct { SkipSettingsSave bool `json:"-"` } +const strmTreeIgnoredItemSampleLimit = 20 + type GenerateSTRMResult struct { LibraryID string `json:"library_id"` OutputDir string `json:"output_dir"` @@ -34,8 +36,10 @@ type GenerateSTRMResult struct { Total int `json:"total,omitempty"` Remaining int `json:"remaining,omitempty"` BatchLimited bool `json:"batch_limited,omitempty"` + Ignored int `json:"ignored,omitempty"` Previewed int `json:"previewed,omitempty"` Errors []string `json:"errors,omitempty"` + IgnoredItems []string `json:"ignored_items,omitempty"` Items []GenerateSTRMItem `json:"items,omitempty"` } @@ -292,6 +296,13 @@ func (r *GenerateSTRMResult) merge(part *GenerateSTRMResult) { r.Skipped += part.Skipped r.Cleaned += part.Cleaned r.Previewed += part.Previewed + r.Ignored += part.Ignored r.Errors = append(r.Errors, part.Errors...) + for _, item := range part.IgnoredItems { + if len(r.IgnoredItems) >= strmTreeIgnoredItemSampleLimit { + break + } + r.IgnoredItems = append(r.IgnoredItems, item) + } r.Items = append(r.Items, part.Items...) } diff --git a/internal/service/strm_tree_import.go b/internal/service/strm_tree_import.go index e96821f..4aed870 100644 --- a/internal/service/strm_tree_import.go +++ b/internal/service/strm_tree_import.go @@ -29,6 +29,12 @@ type strmTreeSource struct { RefPath string } +type strmTreeSourceCollection struct { + sources []strmTreeSource + ignored []string + ignoredCount int +} + func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) { provider := normalizeSTRMTreeProvider(opts.Provider) if provider == "" { @@ -44,8 +50,11 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre } } result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir} - sources := collectSTRMTreeSources(opts) + collection := collectSTRMTreeSources(opts) + sources := collection.sources result.Total = len(sources) + result.Ignored = collection.ignoredCount + result.IgnoredItems = collection.ignored expectedFiles := make(map[string]struct{}) for i, source := range sources { select { @@ -131,13 +140,30 @@ func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts Generate return item } -func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) []strmTreeSource { +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()) @@ -150,10 +176,14 @@ func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) []strmTreeSource { for _, value := range opts.Paths { add(value) } - for _, value := range parseSTRMTreeText(opts.TreeText) { + treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText) + for _, value := range treeSources { add(value) } - return out + for _, value := range treeIgnored { + addIgnored(value) + } + return strmTreeSourceCollection{sources: out, ignored: ignored, ignoredCount: 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 0af400b..5dc4e8b 100644 --- a/internal/service/strm_tree_import_test.go +++ b/internal/service/strm_tree_import_test.go @@ -3,6 +3,7 @@ package service import ( "os" "path/filepath" + "strconv" "strings" "testing" @@ -82,6 +83,65 @@ func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) { } } +func TestGenerateSTRMFromTreeReportsIgnoredFileLikeRows(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/poster.jpg", + "/Movies/fanart.jpg (cover image)", + "/Movies/Disc.Image.2026.iso", + "/Movies/Existing.strm", + }, + TreeText: strings.Join([]string{ + "电视剧", + "└── Show.Name.2026", + " ├── Show.S01E01.mp4", + " └── Show.S01E01.nfo", + " └── Show.S01E01.srt 72 KB", + }, "\n"), + OutputDir: outDir, + }) + if err != nil { + t.Fatal(err) + } + if res.Generated != 2 || res.Ignored != 6 || len(res.IgnoredItems) != 6 { + t.Fatalf("result = %#v, want two generated videos and six ignored sidecars", res) + } + for _, item := range res.IgnoredItems { + if item == "Show.Name.2026" || item == "电视剧/Show.Name.2026" { + t.Fatalf("directory-like dotted title should not be reported as ignored: %#v", res.IgnoredItems) + } + } + if !strings.Contains(strings.Join(res.IgnoredItems, "\n"), "Show.S01E01.nfo") { + t.Fatalf("directory-like dotted title should not be reported as ignored: %#v", res.IgnoredItems) + } +} + +func TestGenerateSTRMFromTreeLimitsIgnoredItemSamples(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "strm") + paths := make([]string, 0, 25) + for i := 0; i < 25; i++ { + paths = append(paths, filepath.ToSlash(filepath.Join("/Movies", "sidecar-"+strconv.Itoa(i)+".nfo"))) + } + svc := NewSTRMService(zap.NewNop(), nil, nil) + + res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ + Provider: "openlist", + Paths: paths, + OutputDir: outDir, + }) + if err != nil { + t.Fatal(err) + } + if res.Ignored != 25 || len(res.IgnoredItems) != strmTreeIgnoredItemSampleLimit { + t.Fatalf("ignored = %d samples = %d, want 25/%d", res.Ignored, len(res.IgnoredItems), strmTreeIgnoredItemSampleLimit) + } +} + func TestGenerateSTRMFromTreeDryRunDoesNotWriteOrCleanup(t *testing.T) { outDir := filepath.Join(t.TempDir(), "strm") stale := filepath.Join(outDir, "Movies", "Old.Movie.strm") diff --git a/internal/service/strm_tree_normalize.go b/internal/service/strm_tree_normalize.go index a72c20b..c91b64f 100644 --- a/internal/service/strm_tree_normalize.go +++ b/internal/service/strm_tree_normalize.go @@ -11,6 +11,26 @@ var strmTreeListPrefixRE = regexp.MustCompile(`^\s*(?:[-*+•·]\s+|\d{1,4}[\.) var strmTreeURLPathQueryKeys = []string{"path", "ref", "file", "filepath", "filename", "name"} +var strmTreeKnownNonVideoExtensions = map[string]struct{}{ + ".ass": {}, + ".bmp": {}, + ".gif": {}, + ".idx": {}, + ".iso": {}, + ".jpeg": {}, + ".jpg": {}, + ".nfo": {}, + ".png": {}, + ".srt": {}, + ".ssa": {}, + ".strm": {}, + ".sub": {}, + ".txt": {}, + ".url": {}, + ".vtt": {}, + ".webp": {}, +} + func normalizeSTRMTreeProvider(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "115", "115pan", "pan115", "cloud115": @@ -199,6 +219,28 @@ func strmTreeSourceIsVideo(source string) bool { return ok } +func strmTreeIgnoredFileLikeSource(raw string) (string, bool) { + source := normalizeSTRMTreeSource(raw) + if source == "" { + return "", false + } + source = trimSTRMTreeKnownFileMetadata(cleanSTRMTreeItemName(source)) + ext := strings.ToLower(path.Ext(source)) + if ext == "" { + return "", false + } + if ext == ".strm" { + return source, true + } + if _, ok := videoExtensions[ext]; ok { + return "", false + } + if _, ok := strmTreeKnownNonVideoExtensions[ext]; ok { + return source, true + } + return "", false +} + func cleanSTRMTreePathDecorations(value string) string { if value == "" { return "" @@ -243,24 +285,34 @@ func cleanSTRMTreeItemName(value string) string { } func trimSTRMTreeMediaMetadata(value string) string { + return trimSTRMTreeMetadataByExtensions(value, videoExtensions) +} + +func trimSTRMTreeKnownFileMetadata(value string) string { + return trimSTRMTreeMetadataByExtensions(value, videoExtensions, strmTreeKnownNonVideoExtensions) +} + +func trimSTRMTreeMetadataByExtensions(value string, extensionSets ...map[string]struct{}) string { value = strings.TrimSpace(value) lower := strings.ToLower(value) bestEnd := -1 - for ext := range videoExtensions { - from := 0 - for { - idx := strings.Index(lower[from:], ext) - if idx < 0 { - break - } - idx += from - end := idx + len(ext) - if strmTreeMediaExtBoundary(value, end) && end > bestEnd { - bestEnd = end - } - from = idx + 1 - if from >= len(lower) { - break + for _, extensions := range extensionSets { + for ext := range extensions { + from := 0 + for { + idx := strings.Index(lower[from:], ext) + if idx < 0 { + break + } + idx += from + end := idx + len(ext) + if strmTreeMediaExtBoundary(value, end) && end > bestEnd { + bestEnd = end + } + from = idx + 1 + if from >= len(lower) { + break + } } } } diff --git a/internal/service/strm_tree_parse.go b/internal/service/strm_tree_parse.go index d80e2ba..5f8c5ef 100644 --- a/internal/service/strm_tree_parse.go +++ b/internal/service/strm_tree_parse.go @@ -6,7 +6,13 @@ import ( ) func parseSTRMTreeText(raw string) []string { + out, _ := parseSTRMTreeTextWithIgnored(raw) + return out +} + +func parseSTRMTreeTextWithIgnored(raw string) ([]string, []string) { var out []string + var ignored []string stack := make([]string, 0, 8) plainIndents := make([]int, 0, 8) rootOffset := 0 @@ -23,6 +29,10 @@ func parseSTRMTreeText(raw string) []string { out = append(out, strmTreeJoinedSource(stack, item)) continue } + if strmTreeItemIsIgnoredFileSource(item) { + ignored = append(ignored, strmTreeJoinedSource(stack, item)) + continue + } stack = append(stack, item) continue } @@ -30,7 +40,11 @@ func parseSTRMTreeText(raw string) []string { plainIndents = plainIndents[:0] level += rootOffset stack = stack[:min(level, len(stack))] - out = append(out, path.Join(append(append([]string{}, stack...), item)...)) + if strmTreeItemIsVideoSource(item) { + out = append(out, path.Join(append(append([]string{}, stack...), item)...)) + } else if strmTreeItemIsIgnoredFileSource(item) { + ignored = append(ignored, path.Join(append(append([]string{}, stack...), item)...)) + } continue } item := cleanSTRMTreeItemName(line) @@ -44,20 +58,32 @@ func parseSTRMTreeText(raw string) []string { out = append(out, strmTreeJoinedSource(stack, item)) continue } + if strmTreeItemIsIgnoredFileSource(item) { + ignored = append(ignored, strmTreeJoinedSource(stack, item)) + continue + } stack = append(stack, item) plainIndents = append(plainIndents, indent) continue } if strmTreeItemIsVideoSource(item) || strings.ContainsAny(item, `/\`) { plainIndents = plainIndents[:0] - out = append(out, item) + if strmTreeItemIsVideoSource(item) { + out = append(out, item) + } else if strmTreeItemIsIgnoredFileSource(item) { + ignored = append(ignored, item) + } + continue + } + if strmTreeItemIsIgnoredFileSource(item) { + ignored = append(ignored, item) continue } stack = []string{item} plainIndents = plainIndents[:0] rootOffset = 1 } - return out + return out, ignored } func parseSTRMTreeLine(line string) (string, int, bool) { @@ -97,7 +123,7 @@ func parseSTRMTreeContinuationLine(line string, plainTreeActive bool) (string, i return "", 0, false } item := cleanSTRMTreeItemName(line[prefixLen:]) - if item == "" || !strmTreeItemIsVideoSource(item) { + if item == "" || (!strmTreeItemIsVideoSource(item) && !strmTreeItemIsIgnoredFileSource(item)) { return "", 0, false } return item, strmTreeIndentLevel(line[:prefixLen]), true @@ -152,6 +178,11 @@ func strmTreeItemIsVideoSource(item string) bool { return source.Path != "" && strmTreeSourceIsVideo(source.Path) } +func strmTreeItemIsIgnoredFileSource(item string) bool { + _, ok := strmTreeIgnoredFileLikeSource(item) + return ok +} + func strmTreeJoinedSource(stack []string, item string) string { if strmTreeItemIsAbsoluteSource(item) { return item diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 27cc27f..e07dc7e 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -20,8 +20,10 @@ export type GenerateSTRMResult = { total?: number remaining?: number batch_limited?: boolean + ignored?: number previewed?: number errors?: string[] + ignored_items?: string[] items?: Array<{ media_id: string title: string diff --git a/web/src/pages/StrmGenerateSectionParts.tsx b/web/src/pages/StrmGenerateSectionParts.tsx index 345b3fc..bfd553b 100644 --- a/web/src/pages/StrmGenerateSectionParts.tsx +++ b/web/src/pages/StrmGenerateSectionParts.tsx @@ -273,11 +273,17 @@ export function StrmGenerateResultPanel({ result }: { result: GenerateSTRMResult