mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +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"`
|
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()})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
"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)
|
||||||
|
|||||||
@@ -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)} />
|
||||||
生成后刷新媒体库
|
生成后刷新媒体库
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user