mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
feat: skip existing media in STRM tree generation
This commit is contained in:
@@ -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()})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, "/"))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)} />
|
||||
生成后刷新媒体库
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user