feat: report ignored STRM tree sidecars

This commit is contained in:
ShukeBta
2026-07-02 22:14:16 +08:00
parent 1afc69baa0
commit ccd9756b6f
7 changed files with 216 additions and 24 deletions
+11
View File
@@ -24,6 +24,8 @@ type GenerateSTRMOptions struct {
SkipSettingsSave bool `json:"-"` SkipSettingsSave bool `json:"-"`
} }
const strmTreeIgnoredItemSampleLimit = 20
type GenerateSTRMResult struct { type GenerateSTRMResult struct {
LibraryID string `json:"library_id"` LibraryID string `json:"library_id"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
@@ -34,8 +36,10 @@ type GenerateSTRMResult struct {
Total int `json:"total,omitempty"` Total int `json:"total,omitempty"`
Remaining int `json:"remaining,omitempty"` Remaining int `json:"remaining,omitempty"`
BatchLimited bool `json:"batch_limited,omitempty"` BatchLimited bool `json:"batch_limited,omitempty"`
Ignored int `json:"ignored,omitempty"`
Previewed int `json:"previewed,omitempty"` Previewed int `json:"previewed,omitempty"`
Errors []string `json:"errors,omitempty"` Errors []string `json:"errors,omitempty"`
IgnoredItems []string `json:"ignored_items,omitempty"`
Items []GenerateSTRMItem `json:"items,omitempty"` Items []GenerateSTRMItem `json:"items,omitempty"`
} }
@@ -292,6 +296,13 @@ func (r *GenerateSTRMResult) merge(part *GenerateSTRMResult) {
r.Skipped += part.Skipped r.Skipped += part.Skipped
r.Cleaned += part.Cleaned r.Cleaned += part.Cleaned
r.Previewed += part.Previewed r.Previewed += part.Previewed
r.Ignored += part.Ignored
r.Errors = append(r.Errors, part.Errors...) 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...) r.Items = append(r.Items, part.Items...)
} }
+34 -4
View File
@@ -29,6 +29,12 @@ type strmTreeSource struct {
RefPath string RefPath string
} }
type strmTreeSourceCollection struct {
sources []strmTreeSource
ignored []string
ignoredCount int
}
func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) { func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) {
provider := normalizeSTRMTreeProvider(opts.Provider) provider := normalizeSTRMTreeProvider(opts.Provider)
if provider == "" { if provider == "" {
@@ -44,8 +50,11 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre
} }
} }
result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir} result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir}
sources := collectSTRMTreeSources(opts) collection := collectSTRMTreeSources(opts)
sources := collection.sources
result.Total = len(sources) result.Total = len(sources)
result.Ignored = collection.ignoredCount
result.IgnoredItems = collection.ignored
expectedFiles := make(map[string]struct{}) expectedFiles := make(map[string]struct{})
for i, source := range sources { for i, source := range sources {
select { select {
@@ -131,13 +140,30 @@ func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts Generate
return item return item
} }
func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) []strmTreeSource { func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection {
fallbackProvider := normalizeSTRMTreeProvider(opts.Provider) fallbackProvider := normalizeSTRMTreeProvider(opts.Provider)
out := make([]strmTreeSource, 0, len(opts.Paths)) out := make([]strmTreeSource, 0, len(opts.Paths))
seen := map[string]struct{}{} 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) { add := func(value string) {
source := normalizeSTRMTreeSourceWithProvider(value, fallbackProvider) source := normalizeSTRMTreeSourceWithProvider(value, fallbackProvider)
if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) { if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) {
addIgnored(value)
return return
} }
key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath()) 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 { for _, value := range opts.Paths {
add(value) add(value)
} }
for _, value := range parseSTRMTreeText(opts.TreeText) { treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText)
for _, value := range treeSources {
add(value) add(value)
} }
return out for _, value := range treeIgnored {
addIgnored(value)
}
return strmTreeSourceCollection{sources: out, ignored: ignored, ignoredCount: ignoredCount}
} }
func (s strmTreeSource) cloudRefPath() string { func (s strmTreeSource) cloudRefPath() string {
+60
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "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) { func TestGenerateSTRMFromTreeDryRunDoesNotWriteOrCleanup(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm") outDir := filepath.Join(t.TempDir(), "strm")
stale := filepath.Join(outDir, "Movies", "Old.Movie.strm") stale := filepath.Join(outDir, "Movies", "Old.Movie.strm")
+67 -15
View File
@@ -11,6 +11,26 @@ var strmTreeListPrefixRE = regexp.MustCompile(`^\s*(?:[-*+•·]\s+|\d{1,4}[\.)
var strmTreeURLPathQueryKeys = []string{"path", "ref", "file", "filepath", "filename", "name"} 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 { func normalizeSTRMTreeProvider(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) { switch strings.ToLower(strings.TrimSpace(raw)) {
case "115", "115pan", "pan115", "cloud115": case "115", "115pan", "pan115", "cloud115":
@@ -199,6 +219,28 @@ func strmTreeSourceIsVideo(source string) bool {
return ok 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 { func cleanSTRMTreePathDecorations(value string) string {
if value == "" { if value == "" {
return "" return ""
@@ -243,24 +285,34 @@ func cleanSTRMTreeItemName(value string) string {
} }
func trimSTRMTreeMediaMetadata(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) value = strings.TrimSpace(value)
lower := strings.ToLower(value) lower := strings.ToLower(value)
bestEnd := -1 bestEnd := -1
for ext := range videoExtensions { for _, extensions := range extensionSets {
from := 0 for ext := range extensions {
for { from := 0
idx := strings.Index(lower[from:], ext) for {
if idx < 0 { idx := strings.Index(lower[from:], ext)
break if idx < 0 {
} break
idx += from }
end := idx + len(ext) idx += from
if strmTreeMediaExtBoundary(value, end) && end > bestEnd { end := idx + len(ext)
bestEnd = end if strmTreeMediaExtBoundary(value, end) && end > bestEnd {
} bestEnd = end
from = idx + 1 }
if from >= len(lower) { from = idx + 1
break if from >= len(lower) {
break
}
} }
} }
} }
+35 -4
View File
@@ -6,7 +6,13 @@ import (
) )
func parseSTRMTreeText(raw string) []string { func parseSTRMTreeText(raw string) []string {
out, _ := parseSTRMTreeTextWithIgnored(raw)
return out
}
func parseSTRMTreeTextWithIgnored(raw string) ([]string, []string) {
var out []string var out []string
var ignored []string
stack := make([]string, 0, 8) stack := make([]string, 0, 8)
plainIndents := make([]int, 0, 8) plainIndents := make([]int, 0, 8)
rootOffset := 0 rootOffset := 0
@@ -23,6 +29,10 @@ func parseSTRMTreeText(raw string) []string {
out = append(out, strmTreeJoinedSource(stack, item)) out = append(out, strmTreeJoinedSource(stack, item))
continue continue
} }
if strmTreeItemIsIgnoredFileSource(item) {
ignored = append(ignored, strmTreeJoinedSource(stack, item))
continue
}
stack = append(stack, item) stack = append(stack, item)
continue continue
} }
@@ -30,7 +40,11 @@ func parseSTRMTreeText(raw string) []string {
plainIndents = plainIndents[:0] plainIndents = plainIndents[:0]
level += rootOffset level += rootOffset
stack = stack[:min(level, len(stack))] 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 continue
} }
item := cleanSTRMTreeItemName(line) item := cleanSTRMTreeItemName(line)
@@ -44,20 +58,32 @@ func parseSTRMTreeText(raw string) []string {
out = append(out, strmTreeJoinedSource(stack, item)) out = append(out, strmTreeJoinedSource(stack, item))
continue continue
} }
if strmTreeItemIsIgnoredFileSource(item) {
ignored = append(ignored, strmTreeJoinedSource(stack, item))
continue
}
stack = append(stack, item) stack = append(stack, item)
plainIndents = append(plainIndents, indent) plainIndents = append(plainIndents, indent)
continue continue
} }
if strmTreeItemIsVideoSource(item) || strings.ContainsAny(item, `/\`) { if strmTreeItemIsVideoSource(item) || strings.ContainsAny(item, `/\`) {
plainIndents = plainIndents[:0] 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 continue
} }
stack = []string{item} stack = []string{item}
plainIndents = plainIndents[:0] plainIndents = plainIndents[:0]
rootOffset = 1 rootOffset = 1
} }
return out return out, ignored
} }
func parseSTRMTreeLine(line string) (string, int, bool) { func parseSTRMTreeLine(line string) (string, int, bool) {
@@ -97,7 +123,7 @@ func parseSTRMTreeContinuationLine(line string, plainTreeActive bool) (string, i
return "", 0, false return "", 0, false
} }
item := cleanSTRMTreeItemName(line[prefixLen:]) item := cleanSTRMTreeItemName(line[prefixLen:])
if item == "" || !strmTreeItemIsVideoSource(item) { if item == "" || (!strmTreeItemIsVideoSource(item) && !strmTreeItemIsIgnoredFileSource(item)) {
return "", 0, false return "", 0, false
} }
return item, strmTreeIndentLevel(line[:prefixLen]), true return item, strmTreeIndentLevel(line[:prefixLen]), true
@@ -152,6 +178,11 @@ func strmTreeItemIsVideoSource(item string) bool {
return source.Path != "" && strmTreeSourceIsVideo(source.Path) return source.Path != "" && strmTreeSourceIsVideo(source.Path)
} }
func strmTreeItemIsIgnoredFileSource(item string) bool {
_, ok := strmTreeIgnoredFileLikeSource(item)
return ok
}
func strmTreeJoinedSource(stack []string, item string) string { func strmTreeJoinedSource(stack []string, item string) string {
if strmTreeItemIsAbsoluteSource(item) { if strmTreeItemIsAbsoluteSource(item) {
return item return item
+2
View File
@@ -20,8 +20,10 @@ export type GenerateSTRMResult = {
total?: number total?: number
remaining?: number remaining?: number
batch_limited?: boolean batch_limited?: boolean
ignored?: number
previewed?: number previewed?: number
errors?: string[] errors?: string[]
ignored_items?: string[]
items?: Array<{ items?: Array<{
media_id: string media_id: string
title: string title: string
+7 -1
View File
@@ -273,11 +273,17 @@ export function StrmGenerateResultPanel({ result }: { result: GenerateSTRMResult
<div className="mt-1"> <div className="mt-1">
{result.previewed ? `预检 ${result.previewed} · ` : ''} {result.previewed ? `预检 ${result.previewed} · ` : ''}
{result.total ? `共 ${result.total} · ` : ''} {result.total ? `共 ${result.total} · ` : ''}
新增 {result.generated} · 更新 {result.updated} · 跳过 {result.skipped} · 清理 {result.cleaned || 0} 新增 {result.generated} · 更新 {result.updated} · 跳过 {result.skipped}
{result.ignored ? ` · 忽略 ${result.ignored}` : ''} · 清理 {result.cleaned || 0}
</div> </div>
{result.batch_limited && ( {result.batch_limited && (
<div className="mt-1 text-amber-600">本批已达到上限,剩余 {result.remaining || 0} 个可继续运行。</div> <div className="mt-1 text-amber-600">本批已达到上限,剩余 {result.remaining || 0} 个可继续运行。</div>
)} )}
{result.ignored_items && result.ignored_items.length > 0 && (
<div className="mt-1 text-amber-600">
已忽略 {result.ignored || result.ignored_items.length} 个非视频/sidecar:{result.ignored_items.slice(0, 3).join(';')}
</div>
)}
{result.errors && result.errors.length > 0 && ( {result.errors && result.errors.length > 0 && (
<div className="mt-2 text-red-500"> <div className="mt-2 text-red-500">
失败 {result.errors.length} 条:{result.errors.slice(0, 3).join(';')} 失败 {result.errors.length} 条:{result.errors.slice(0, 3).join(';')}