From 077bb10d255bc650aaa05e728bdaa60ac67fb1f9 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:35:24 +0800 Subject: [PATCH] feat: skip existing media in STRM tree generation --- internal/handler/strm.go | 2 + internal/service/strm_tree_import.go | 14 ++++- internal/service/strm_tree_missing.go | 73 +++++++++++++++++++++++ internal/service/strm_tree_url_test.go | 61 +++++++++++++++++++ web/src/api/strm.ts | 1 + web/src/pages/StrmTreeGenerateSection.tsx | 6 ++ web/src/pages/useStrmTreeGenerateForm.ts | 4 ++ 7 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 internal/service/strm_tree_missing.go diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 02b0791..944ca39 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -123,6 +123,7 @@ type generateSTRMTreeReq struct { DryRun bool `json:"dry_run"` BatchLimit int `json:"batch_limit"` TransferSubtitles bool `json:"transfer_subtitles"` + MissingOnly bool `json:"missing_only"` RefreshLibrary bool `json:"refresh_library"` } @@ -208,6 +209,7 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc { DryRun: req.DryRun, BatchLimit: req.BatchLimit, TransferSubtitles: req.TransferSubtitles, + MissingOnly: req.MissingOnly, }) 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 3144368..093c27e 100644 --- a/internal/service/strm_tree_import.go +++ b/internal/service/strm_tree_import.go @@ -22,6 +22,7 @@ type GenerateSTRMTreeOptions struct { DryRun bool `json:"dry_run"` BatchLimit int `json:"batch_limit,omitempty"` TransferSubtitles bool `json:"transfer_subtitles,omitempty"` + MissingOnly bool `json:"missing_only,omitempty"` } type strmTreeSource struct { @@ -73,6 +74,10 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre result.Total = len(sources) result.Ignored = collection.ignoredCount result.IgnoredItems = collection.ignored + existingRefs, err := s.strmTreeExistingCloudRefs(ctx, opts) + if err != nil { + return nil, err + } expectedFiles := make(map[string]struct{}) for i, source := range sources { select { @@ -80,7 +85,7 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre return result, ctx.Err() default: } - item := generateTreeSTRMItem(outputDir, source, opts) + item := generateTreeSTRMItem(outputDir, source, opts, existingRefs) if item.FilePath != "" && item.Action != "error" { expectedFiles[filepath.Clean(item.FilePath)] = struct{}{} } @@ -112,7 +117,7 @@ func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool { return result.Generated+result.Updated+result.Previewed >= limit } -func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions) GenerateSTRMItem { +func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem { relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot) relPath, err := strmTreeOutputRelativePath(relSource) if source.Kind == strmTreeSourceKindSubtitle { @@ -133,6 +138,11 @@ func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts Generate filePath := filepath.Join(outputDir, prefix, relPath) item.FilePath = filePath item.URL = absolutizeSTRMURL(BuildRelativeCloudPlayURL(source.Provider, strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot)), opts.BaseURL) + if strmTreeSourceAlreadyInLibrary(source, opts, existingRefs) { + item.Action = "skipped" + item.Reason = "already in media library" + return item + } if _, err := os.Stat(filePath); err == nil && !opts.Overwrite { item.Action = "skipped" item.Reason = "target exists" diff --git a/internal/service/strm_tree_missing.go b/internal/service/strm_tree_missing.go new file mode 100644 index 0000000..a7265af --- /dev/null +++ b/internal/service/strm_tree_missing.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + "errors" + "net/url" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (s *STRMService) strmTreeExistingCloudRefs(ctx context.Context, opts GenerateSTRMTreeOptions) (map[string]struct{}, error) { + if !opts.MissingOnly { + return nil, nil + } + if s == nil || s.repo == nil || s.repo.DB == nil { + return nil, errors.New("media library unavailable") + } + var rows []model.Media + if err := s.repo.DB.WithContext(ctx). + Select("path", "strm_url"). + Where("strm_url <> '' OR path LIKE ?", "cloud://%"). + Find(&rows).Error; err != nil { + return nil, err + } + refs := make(map[string]struct{}, len(rows)) + for _, row := range rows { + if typ, ref, ok := parseCloudMediaPlaybackURL(row.STRMURL); ok { + refs[strmTreeCloudRefKey(typ, ref)] = struct{}{} + } + if typ, ref, ok := strmTreeCloudPathTarget(row.Path); ok { + refs[strmTreeCloudRefKey(typ, ref)] = struct{}{} + } + } + return refs, nil +} + +func strmTreeSourceAlreadyInLibrary(source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) bool { + if len(existingRefs) == 0 || source.Provider == "" || source.Path == "" || source.Kind == strmTreeSourceKindSubtitle { + return false + } + ref := strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot) + _, ok := existingRefs[strmTreeCloudRefKey(source.Provider, ref)] + return ok +} + +func strmTreeCloudPathTarget(raw string) (string, string, bool) { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { + return "", "", false + } + rest := strings.TrimPrefix(raw, "cloud://") + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 { + return "", "", false + } + typ := strings.TrimSpace(parts[0]) + ref := strings.TrimSpace(parts[1]) + return typ, ref, typ != "" && ref != "" +} + +func strmTreeCloudRefKey(provider, ref string) string { + return strings.ToLower(normalizeSTRMTreeProvider(provider)) + "\x00" + strmTreeNormalizeCloudRef(ref) +} + +func strmTreeNormalizeCloudRef(ref string) string { + ref = strings.TrimSpace(ref) + if decoded, err := url.PathUnescape(ref); err == nil { + ref = decoded + } + ref = strings.TrimSpace(strings.ReplaceAll(ref, "\\", "/")) + return strings.ToLower(strings.TrimLeft(ref, "/")) +} diff --git a/internal/service/strm_tree_url_test.go b/internal/service/strm_tree_url_test.go index e4d01e4..2690e97 100644 --- a/internal/service/strm_tree_url_test.go +++ b/internal/service/strm_tree_url_test.go @@ -8,6 +8,9 @@ import ( "testing" "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" ) func TestGenerateSTRMFromTreeUsesMediaPathFromURLQuery(t *testing.T) { @@ -164,6 +167,64 @@ func TestGenerateSTRMFromTreeDoesNotDedupeDifferentProviders(t *testing.T) { } } +func TestGenerateSTRMFromTreeMissingOnlySkipsMediaAlreadyInLibrary(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + rows := []model.Media{ + { + Base: model.Base{ID: "existing-strm"}, + Title: "Existing STRM", + Path: "cloud://openlist/Movies/Existing.Path.Copy.mkv", + STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FExisting.Movie.mkv", + }, + { + Base: model.Base{ID: "existing-cloud-path"}, + Title: "Existing Cloud Path", + Path: "cloud://cloud115/Shows/Existing.Show.S01E01.mkv", + }, + { + Base: model.Base{ID: "different-provider"}, + Title: "Different Provider", + Path: "cloud://cloud115/Shows/Same.Provider.mkv", + }, + } + for i := range rows { + if err := repos.DB.Create(&rows[i]).Error; err != nil { + t.Fatal(err) + } + } + outDir := filepath.Join(t.TempDir(), "strm") + svc := NewSTRMService(zap.NewNop(), repos, nil) + + res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ + Provider: "openlist", + Paths: []string{ + "cloud://openlist/Movies/Existing.Movie.mkv", + "cloud://openlist/Movies/New.Movie.mkv", + "cloud://cloud115/Shows/Existing.Show.S01E01.mkv", + "cloud://openlist/Shows/Same.Provider.mkv", + }, + OutputDir: outDir, + MissingOnly: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Generated != 2 || res.Skipped != 2 || len(res.Errors) != 0 { + t.Fatalf("result = %#v, want generated=2 skipped=2", res) + } + if _, err := os.Stat(filepath.Join(outDir, "Movies", "Existing.Movie.strm")); !os.IsNotExist(err) { + t.Fatalf("existing media should not generate STRM, stat err=%v", err) + } + assertFileContains(t, filepath.Join(outDir, "Movies", "New.Movie.strm"), "/api/cloud/play/openlist?ref=%2FMovies%2FNew.Movie.mkv") + assertFileContains(t, filepath.Join(outDir, "Shows", "Same.Provider.strm"), "/api/cloud/play/openlist?ref=%2FShows%2FSame.Provider.mkv") + for _, item := range res.Items { + if item.Title == "Existing.Movie" && item.Reason != "already in media library" { + t.Fatalf("existing media skip reason = %q", item.Reason) + } + } +} + func TestGenerateSTRMFromTreeStripsPathListPrefixes(t *testing.T) { outDir := filepath.Join(t.TempDir(), "strm") svc := NewSTRMService(zap.NewNop(), nil, nil) diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 6116ab2..ad8c664 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -49,6 +49,7 @@ export type GenerateSTRMTreeInput = { dry_run?: boolean batch_limit?: number transfer_subtitles?: boolean + missing_only?: boolean refresh_library?: boolean } diff --git a/web/src/pages/StrmTreeGenerateSection.tsx b/web/src/pages/StrmTreeGenerateSection.tsx index d7d56c7..9caaac8 100644 --- a/web/src/pages/StrmTreeGenerateSection.tsx +++ b/web/src/pages/StrmTreeGenerateSection.tsx @@ -32,6 +32,7 @@ export function StrmTreeGenerateSection({ batchLimit, cleanup, generating, + missingOnly, onGenerate, onPreview, onImportTreeFile, @@ -46,6 +47,7 @@ export function StrmTreeGenerateSection({ setBaseURL, setBatchLimit, setCleanup, + setMissingOnly, setOutputDir, setOutputPrefix, setOverwrite, @@ -187,6 +189,10 @@ export function StrmTreeGenerateSection({ setTransferSubtitles(e.target.checked)} /> 转移同名字幕链接 +