feat: skip existing media in STRM tree generation

This commit is contained in:
ShukeBta
2026-07-02 23:35:24 +08:00
parent e700962f2a
commit 077bb10d25
7 changed files with 159 additions and 2 deletions
+2
View File
@@ -123,6 +123,7 @@ type generateSTRMTreeReq struct {
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
BatchLimit int `json:"batch_limit"` BatchLimit int `json:"batch_limit"`
TransferSubtitles bool `json:"transfer_subtitles"` TransferSubtitles bool `json:"transfer_subtitles"`
MissingOnly bool `json:"missing_only"`
RefreshLibrary bool `json:"refresh_library"` RefreshLibrary bool `json:"refresh_library"`
} }
@@ -208,6 +209,7 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc {
DryRun: req.DryRun, DryRun: req.DryRun,
BatchLimit: req.BatchLimit, BatchLimit: req.BatchLimit,
TransferSubtitles: req.TransferSubtitles, TransferSubtitles: req.TransferSubtitles,
MissingOnly: req.MissingOnly,
}) })
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+12 -2
View File
@@ -22,6 +22,7 @@ type GenerateSTRMTreeOptions struct {
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
BatchLimit int `json:"batch_limit,omitempty"` BatchLimit int `json:"batch_limit,omitempty"`
TransferSubtitles bool `json:"transfer_subtitles,omitempty"` TransferSubtitles bool `json:"transfer_subtitles,omitempty"`
MissingOnly bool `json:"missing_only,omitempty"`
} }
type strmTreeSource struct { type strmTreeSource struct {
@@ -73,6 +74,10 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre
result.Total = len(sources) result.Total = len(sources)
result.Ignored = collection.ignoredCount result.Ignored = collection.ignoredCount
result.IgnoredItems = collection.ignored result.IgnoredItems = collection.ignored
existingRefs, err := s.strmTreeExistingCloudRefs(ctx, opts)
if err != nil {
return nil, err
}
expectedFiles := make(map[string]struct{}) expectedFiles := make(map[string]struct{})
for i, source := range sources { for i, source := range sources {
select { select {
@@ -80,7 +85,7 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre
return result, ctx.Err() return result, ctx.Err()
default: default:
} }
item := generateTreeSTRMItem(outputDir, source, opts) item := generateTreeSTRMItem(outputDir, source, opts, existingRefs)
if item.FilePath != "" && item.Action != "error" { if item.FilePath != "" && item.Action != "error" {
expectedFiles[filepath.Clean(item.FilePath)] = struct{}{} 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 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) relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
relPath, err := strmTreeOutputRelativePath(relSource) relPath, err := strmTreeOutputRelativePath(relSource)
if source.Kind == strmTreeSourceKindSubtitle { if source.Kind == strmTreeSourceKindSubtitle {
@@ -133,6 +138,11 @@ func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts Generate
filePath := filepath.Join(outputDir, prefix, relPath) filePath := filepath.Join(outputDir, prefix, relPath)
item.FilePath = filePath item.FilePath = filePath
item.URL = absolutizeSTRMURL(BuildRelativeCloudPlayURL(source.Provider, strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot)), opts.BaseURL) 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 { if _, err := os.Stat(filePath); err == nil && !opts.Overwrite {
item.Action = "skipped" item.Action = "skipped"
item.Reason = "target exists" item.Reason = "target exists"
+73
View File
@@ -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, "/"))
}
+61
View File
@@ -8,6 +8,9 @@ import (
"testing" "testing"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
) )
func TestGenerateSTRMFromTreeUsesMediaPathFromURLQuery(t *testing.T) { 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) { func TestGenerateSTRMFromTreeStripsPathListPrefixes(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm") outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil) svc := NewSTRMService(zap.NewNop(), nil, nil)
+1
View File
@@ -49,6 +49,7 @@ export type GenerateSTRMTreeInput = {
dry_run?: boolean dry_run?: boolean
batch_limit?: number batch_limit?: number
transfer_subtitles?: boolean transfer_subtitles?: boolean
missing_only?: boolean
refresh_library?: boolean refresh_library?: boolean
} }
@@ -32,6 +32,7 @@ export function StrmTreeGenerateSection({
batchLimit, batchLimit,
cleanup, cleanup,
generating, generating,
missingOnly,
onGenerate, onGenerate,
onPreview, onPreview,
onImportTreeFile, onImportTreeFile,
@@ -46,6 +47,7 @@ export function StrmTreeGenerateSection({
setBaseURL, setBaseURL,
setBatchLimit, setBatchLimit,
setCleanup, setCleanup,
setMissingOnly,
setOutputDir, setOutputDir,
setOutputPrefix, setOutputPrefix,
setOverwrite, setOverwrite,
@@ -187,6 +189,10 @@ export function StrmTreeGenerateSection({
<input type="checkbox" checked={transferSubtitles} onChange={(e) => setTransferSubtitles(e.target.checked)} /> <input type="checkbox" checked={transferSubtitles} onChange={(e) => setTransferSubtitles(e.target.checked)} />
转移同名字幕链接 转移同名字幕链接
</label> </label>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={missingOnly} onChange={(e) => setMissingOnly(e.target.checked)} />
仅生成媒体库缺失
</label>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4"> <label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={refreshLibrary} onChange={(e) => setRefreshLibrary(e.target.checked)} /> <input type="checkbox" checked={refreshLibrary} onChange={(e) => setRefreshLibrary(e.target.checked)} />
生成后刷新媒体库 生成后刷新媒体库
+4
View File
@@ -18,6 +18,7 @@ export function useStrmTreeGenerateForm() {
const [overwrite, setOverwrite] = useState(false) const [overwrite, setOverwrite] = useState(false)
const [cleanup, setCleanup] = useState(false) const [cleanup, setCleanup] = useState(false)
const [transferSubtitles, setTransferSubtitles] = useState(true) const [transferSubtitles, setTransferSubtitles] = useState(true)
const [missingOnly, setMissingOnly] = useState(false)
const [refreshLibrary, setRefreshLibrary] = useState(true) const [refreshLibrary, setRefreshLibrary] = useState(true)
const [runningMode, setRunningMode] = useState<'generate' | 'preview' | null>(null) const [runningMode, setRunningMode] = useState<'generate' | 'preview' | null>(null)
const [result, setResult] = useState<GenerateSTRMResult | null>(null) const [result, setResult] = useState<GenerateSTRMResult | null>(null)
@@ -72,6 +73,7 @@ export function useStrmTreeGenerateForm() {
dry_run: dryRun, dry_run: dryRun,
batch_limit: parsedBatchLimit || undefined, batch_limit: parsedBatchLimit || undefined,
transfer_subtitles: transferSubtitles, transfer_subtitles: transferSubtitles,
missing_only: missingOnly,
refresh_library: refreshLibrary, refresh_library: refreshLibrary,
}) })
setResult(next) setResult(next)
@@ -104,6 +106,7 @@ export function useStrmTreeGenerateForm() {
onImportTreeFile, onImportTreeFile,
onGenerate, onGenerate,
onPreview, onPreview,
missingOnly,
outputDir, outputDir,
outputPrefix, outputPrefix,
overwrite, overwrite,
@@ -115,6 +118,7 @@ export function useStrmTreeGenerateForm() {
setBaseURL, setBaseURL,
setBatchLimit, setBatchLimit,
setCleanup, setCleanup,
setMissingOnly,
setOutputDir, setOutputDir,
setOutputPrefix, setOutputPrefix,
setOverwrite, setOverwrite,