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)} />
转移同名字幕链接
+