mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
feat: report ignored STRM tree sidecars
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(';')}
|
||||
|
||||
Reference in New Issue
Block a user