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"`
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()})
+12 -2
View File
@@ -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"
+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"
"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)
+1
View File
@@ -49,6 +49,7 @@ export type GenerateSTRMTreeInput = {
dry_run?: boolean
batch_limit?: number
transfer_subtitles?: boolean
missing_only?: boolean
refresh_library?: boolean
}
@@ -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({
<input type="checkbox" checked={transferSubtitles} onChange={(e) => setTransferSubtitles(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">
<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">
<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 [cleanup, setCleanup] = useState(false)
const [transferSubtitles, setTransferSubtitles] = useState(true)
const [missingOnly, setMissingOnly] = useState(false)
const [refreshLibrary, setRefreshLibrary] = useState(true)
const [runningMode, setRunningMode] = useState<'generate' | 'preview' | null>(null)
const [result, setResult] = useState<GenerateSTRMResult | null>(null)
@@ -72,6 +73,7 @@ export function useStrmTreeGenerateForm() {
dry_run: dryRun,
batch_limit: parsedBatchLimit || undefined,
transfer_subtitles: transferSubtitles,
missing_only: missingOnly,
refresh_library: refreshLibrary,
})
setResult(next)
@@ -104,6 +106,7 @@ export function useStrmTreeGenerateForm() {
onImportTreeFile,
onGenerate,
onPreview,
missingOnly,
outputDir,
outputPrefix,
overwrite,
@@ -115,6 +118,7 @@ export function useStrmTreeGenerateForm() {
setBaseURL,
setBatchLimit,
setCleanup,
setMissingOnly,
setOutputDir,
setOutputPrefix,
setOverwrite,