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:"-"`
}
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...)
}
+34 -4
View File
@@ -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 {
+60
View File
@@ -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")
+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 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
}
}
}
}
+35 -4
View File
@@ -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
+2
View File
@@ -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
+7 -1
View File
@@ -273,11 +273,17 @@ export function StrmGenerateResultPanel({ result }: { result: GenerateSTRMResult
<div className="mt-1">
{result.previewed ? `预检 ${result.previewed} · ` : ''}
{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>
{result.batch_limited && (
<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 && (
<div className="mt-2 text-red-500">
失败 {result.errors.length} 条:{result.errors.slice(0, 3).join(';')}