diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 4731de8..02b0791 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -7,6 +7,7 @@ package handler import ( + "context" "net/http" "strings" @@ -106,6 +107,7 @@ type generateSTRMReq struct { Overwrite bool `json:"overwrite"` IncludeLocal *bool `json:"include_local"` PreserveTree bool `json:"preserve_tree"` + Refresh bool `json:"refresh_library"` } type generateSTRMTreeReq struct { @@ -121,12 +123,14 @@ type generateSTRMTreeReq struct { DryRun bool `json:"dry_run"` BatchLimit int `json:"batch_limit"` TransferSubtitles bool `json:"transfer_subtitles"` + RefreshLibrary bool `json:"refresh_library"` } type repairSTRMReq struct { - OutputDir string `json:"output_dir" binding:"required"` - BaseURL string `json:"base_url"` - DryRun bool `json:"dry_run"` + OutputDir string `json:"output_dir" binding:"required"` + BaseURL string `json:"base_url"` + DryRun bool `json:"dry_run"` + RefreshLibrary bool `json:"refresh_library"` } func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { @@ -169,6 +173,9 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if req.Refresh { + res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, "STRM 生成后刷新媒体库", strmGenerationChanged(res)) + } c.JSON(http.StatusOK, res) } } @@ -206,6 +213,9 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if req.RefreshLibrary && !req.DryRun { + res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, "STRM 目录树生成后刷新媒体库", strmGenerationChanged(res)) + } c.JSON(http.StatusOK, res) } } @@ -234,10 +244,74 @@ func repairSTRMHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if req.RefreshLibrary && !req.DryRun { + res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, "STRM 修复后刷新媒体库", res.Repaired > 0) + } c.JSON(http.StatusOK, res) } } +func strmGenerationChanged(res *service.GenerateSTRMResult) bool { + return res != nil && (res.Generated > 0 || res.Updated > 0 || res.Cleaned > 0) +} + +func queueSTRMRefreshAfterChanges(ctx context.Context, svc *service.Container, outputDir, taskName string, changed bool) *service.STRMRefreshResult { + refresh := &service.STRMRefreshResult{Requested: true} + if !changed { + refresh.Reason = "no strm changes" + return refresh + } + if svc == nil || svc.Scan == nil { + refresh.Reason = "scanner unavailable" + return refresh + } + targets, err := service.FindSTRMRefreshTargets(ctx, svc.Repo, outputDir) + if err != nil { + refresh.Reason = err.Error() + return refresh + } + if len(targets) == 0 { + refresh.Reason = "no matching local library" + return refresh + } + refresh.Targets = targets + for _, target := range targets { + key := target.LibraryID + if target.RootID != "" { + key += ":" + target.RootID + } + finishScan, ok := svc.Scan.TryBeginLocalScan(key) + if !ok { + continue + } + refresh.Queued = true + task := startScanHTTPTask(svc, taskName, target.Name, target.Path) + go runSTRMRefreshScan(svc, target, task, finishScan) + } + if !refresh.Queued { + refresh.Reason = "matching library already scanning" + } + return refresh +} + +func runSTRMRefreshScan(svc *service.Container, target service.STRMRefreshTarget, task *service.TaskHandle, finish func()) { + defer finish() + var ( + res *service.ScanResult + err error + ) + if target.RootID != "" { + res, err = svc.Scan.ScanLibraryRoot(context.Background(), target.LibraryID, target.RootID) + } else { + res, err = svc.Scan.ScanLibrary(context.Background(), target.LibraryID) + } + if err != nil { + finishHTTPTask(task, err, "scan", "STRM 刷新媒体库失败", scanTaskMetrics(res), scanTaskDetails(res, 20)) + return + } + finishHTTPTask(task, nil, "completed", "STRM 刷新媒体库结束", scanTaskMetrics(res), scanTaskDetails(res, 20)) +} + func strmPlaybackTokenForRequest(c *gin.Context, svc *service.Container) string { if svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil { return "" diff --git a/internal/service/strm_generate.go b/internal/service/strm_generate.go index 24b2efb..0d2628e 100644 --- a/internal/service/strm_generate.go +++ b/internal/service/strm_generate.go @@ -41,6 +41,7 @@ type GenerateSTRMResult struct { Errors []string `json:"errors,omitempty"` IgnoredItems []string `json:"ignored_items,omitempty"` Items []GenerateSTRMItem `json:"items,omitempty"` + Refresh *STRMRefreshResult `json:"refresh,omitempty"` } type GenerateSTRMItem struct { diff --git a/internal/service/strm_refresh.go b/internal/service/strm_refresh.go new file mode 100644 index 0000000..a1a777d --- /dev/null +++ b/internal/service/strm_refresh.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "path/filepath" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +type STRMRefreshResult struct { + Requested bool `json:"requested"` + Queued bool `json:"queued"` + Reason string `json:"reason,omitempty"` + Targets []STRMRefreshTarget `json:"targets,omitempty"` +} + +type STRMRefreshTarget struct { + LibraryID string `json:"library_id"` + RootID string `json:"root_id,omitempty"` + Name string `json:"name"` + Path string `json:"path"` +} + +func FindSTRMRefreshTargets(ctx context.Context, repo *repository.Container, outputDir string) ([]STRMRefreshTarget, error) { + if repo == nil || repo.Library == nil { + return nil, nil + } + outputDir = resolveMappedDestinationPath(strings.TrimSpace(outputDir)) + if outputDir == "" || outputDir == "." { + return nil, nil + } + libraries, err := repo.Library.List(ctx) + if err != nil { + return nil, err + } + targets := make([]STRMRefreshTarget, 0) + seen := map[string]struct{}{} + for i := range libraries { + lib := libraries[i] + if !lib.Enabled { + continue + } + roots, err := repo.Library.ListRoots(ctx, lib.ID) + if err != nil { + return nil, err + } + if len(roots) == 0 && strings.TrimSpace(lib.Path) != "" { + roots = []model.LibraryRoot{{LibraryID: lib.ID, Path: lib.Path, Enabled: lib.Enabled}} + } + for j := range roots { + root := roots[j] + if !root.Enabled || strings.TrimSpace(root.Path) == "" { + continue + } + if _, ok := ParseCloudLibraryMount(root.Path); ok { + continue + } + if !strmRefreshPathMatches(outputDir, root.Path) { + continue + } + key := lib.ID + "\x00" + root.ID + "\x00" + strings.ToLower(filepath.Clean(root.Path)) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + targets = append(targets, STRMRefreshTarget{ + LibraryID: lib.ID, + RootID: root.ID, + Name: lib.Name, + Path: filepath.Clean(root.Path), + }) + } + } + return targets, nil +} + +func strmRefreshPathMatches(outputDir, libraryRoot string) bool { + outputDir = filepath.Clean(strings.TrimSpace(outputDir)) + libraryRoot = filepath.Clean(strings.TrimSpace(libraryRoot)) + if outputDir == "" || outputDir == "." || libraryRoot == "" || libraryRoot == "." { + return false + } + return sameLibraryPath(outputDir, libraryRoot) || pathWithin(outputDir, libraryRoot) || pathWithin(libraryRoot, outputDir) +} diff --git a/internal/service/strm_refresh_test.go b/internal/service/strm_refresh_test.go new file mode 100644 index 0000000..9f80c76 --- /dev/null +++ b/internal/service/strm_refresh_test.go @@ -0,0 +1,65 @@ +package service + +import ( + "path/filepath" + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestFindSTRMRefreshTargetsMatchesNestedLocalRoots(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) + repos := repository.New(db) + base := t.TempDir() + movieRoot := filepath.Join(base, "strm", "电影") + tvRoot := filepath.Join(base, "strm", "电视剧") + otherRoot := filepath.Join(base, "other") + cloudRoot := "cloud://openlist/电影" + + movie := model.Library{Name: "电影 STRM", Path: movieRoot, Type: "movie", Enabled: true} + tv := model.Library{Name: "电视剧 STRM", Path: tvRoot, Type: "tv", Enabled: true} + other := model.Library{Name: "其他", Path: otherRoot, Type: "movie", Enabled: true} + cloud := model.Library{Name: "云盘", Path: cloudRoot, Type: "movie", Enabled: true} + disabled := model.Library{Name: "停用", Path: filepath.Join(base, "strm", "动漫"), Type: "tv", Enabled: false} + for _, lib := range []*model.Library{&movie, &tv, &other, &cloud, &disabled} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + if err := repos.DB.Model(&model.Library{}).Where("id = ?", disabled.ID).Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + + targets, err := FindSTRMRefreshTargets(t.Context(), repos, filepath.Join(base, "strm")) + if err != nil { + t.Fatal(err) + } + if len(targets) != 2 { + t.Fatalf("targets = %#v, want movie and tv only", targets) + } + got := map[string]bool{} + for _, target := range targets { + got[target.LibraryID] = true + } + if !got[movie.ID] || !got[tv.ID] || got[other.ID] || got[cloud.ID] || got[disabled.ID] { + t.Fatalf("target libraries = %#v", targets) + } +} + +func TestFindSTRMRefreshTargetsDoesNotFallbackToAllLibraries(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) + repos := repository.New(db) + lib := model.Library{Name: "电影", Path: filepath.Join(t.TempDir(), "movies"), Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + + targets, err := FindSTRMRefreshTargets(t.Context(), repos, filepath.Join(t.TempDir(), "strm")) + if err != nil { + t.Fatal(err) + } + if len(targets) != 0 { + t.Fatalf("targets = %#v, want no fallback target", targets) + } +} diff --git a/internal/service/strm_repair.go b/internal/service/strm_repair.go index a8c0271..a5088fe 100644 --- a/internal/service/strm_repair.go +++ b/internal/service/strm_repair.go @@ -17,12 +17,13 @@ type RepairSTRMOptions struct { } type RepairSTRMResult struct { - OutputDir string `json:"output_dir"` - Repaired int `json:"repaired"` - Previewed int `json:"previewed,omitempty"` - Skipped int `json:"skipped"` - Errors []string `json:"errors,omitempty"` - Items []RepairSTRMItem `json:"items,omitempty"` + OutputDir string `json:"output_dir"` + Repaired int `json:"repaired"` + Previewed int `json:"previewed,omitempty"` + Skipped int `json:"skipped"` + Errors []string `json:"errors,omitempty"` + Items []RepairSTRMItem `json:"items,omitempty"` + Refresh *STRMRefreshResult `json:"refresh,omitempty"` } type RepairSTRMItem struct { diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 779497f..6116ab2 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -8,6 +8,7 @@ export type GenerateSTRMInput = { overwrite?: boolean include_local?: boolean preserve_tree?: boolean + refresh_library?: boolean } export type GenerateSTRMResult = { @@ -24,6 +25,7 @@ export type GenerateSTRMResult = { previewed?: number errors?: string[] ignored_items?: string[] + refresh?: STRMRefreshResult items?: Array<{ media_id: string title: string @@ -47,12 +49,14 @@ export type GenerateSTRMTreeInput = { dry_run?: boolean batch_limit?: number transfer_subtitles?: boolean + refresh_library?: boolean } export type RepairSTRMInput = { output_dir: string base_url?: string dry_run?: boolean + refresh_library?: boolean } export type RepairSTRMResult = { @@ -61,6 +65,7 @@ export type RepairSTRMResult = { previewed?: number skipped: number errors?: string[] + refresh?: STRMRefreshResult items?: Array<{ file_path: string before?: string @@ -70,6 +75,18 @@ export type RepairSTRMResult = { }> } +export type STRMRefreshResult = { + requested: boolean + queued: boolean + reason?: string + targets?: Array<{ + library_id: string + root_id?: string + name: string + path: string + }> +} + export const strmAPI = { set: (mediaID: string, url: string) => api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data), diff --git a/web/src/pages/StrmGenerateSection.tsx b/web/src/pages/StrmGenerateSection.tsx index 66357a5..49abb83 100644 --- a/web/src/pages/StrmGenerateSection.tsx +++ b/web/src/pages/StrmGenerateSection.tsx @@ -25,6 +25,7 @@ export type StrmGenerateSectionProps = { overwrite: boolean includeLocal: boolean preserveTree: boolean + refreshLibrary: boolean generating: boolean generateResult: GenerateSTRMResult | null playbackStatus: string @@ -40,6 +41,7 @@ export type StrmGenerateSectionProps = { setOverwrite: (value: boolean) => void setIncludeLocal: (value: boolean) => void setPreserveTree: (value: boolean) => void + setRefreshLibrary: (value: boolean) => void } export function StrmGenerateSection(props: StrmGenerateSectionProps) { diff --git a/web/src/pages/StrmGenerateSectionParts.tsx b/web/src/pages/StrmGenerateSectionParts.tsx index bfd553b..06b3ea6 100644 --- a/web/src/pages/StrmGenerateSectionParts.tsx +++ b/web/src/pages/StrmGenerateSectionParts.tsx @@ -144,6 +144,7 @@ type StrmGenerateFormProps = Pick< | 'overwrite' | 'includeLocal' | 'preserveTree' + | 'refreshLibrary' | 'generating' | 'onGenerate' | 'setGenerateLibraryID' @@ -152,6 +153,7 @@ type StrmGenerateFormProps = Pick< | 'setOverwrite' | 'setIncludeLocal' | 'setPreserveTree' + | 'setRefreshLibrary' > export function StrmGenerateForm({ @@ -162,6 +164,7 @@ export function StrmGenerateForm({ overwrite, includeLocal, preserveTree, + refreshLibrary, generating, onGenerate, setGenerateLibraryID, @@ -170,6 +173,7 @@ export function StrmGenerateForm({ setOverwrite, setIncludeLocal, setPreserveTree, + setRefreshLibrary, }: StrmGenerateFormProps) { return (
@@ -188,10 +192,11 @@ export function StrmGenerateForm({ > 使用当前访问地址 -
+
+
本批已达到上限,剩余 {result.remaining || 0} 个可继续运行。
)} + {result.refresh && ( +
+ {result.refresh.queued + ? `媒体库刷新已排队:${result.refresh.targets?.map((target) => target.name).join('、') || '已匹配媒体库'}` + : `媒体库未刷新:${result.refresh.reason || '未匹配到可扫描媒体库'}`} +
+ )} {result.ignored_items && result.ignored_items.length > 0 && (
已忽略 {result.ignored || result.ignored_items.length} 个非视频/sidecar:{result.ignored_items.slice(0, 3).join(';')} diff --git a/web/src/pages/StrmPage.tsx b/web/src/pages/StrmPage.tsx index 60a707b..2f65375 100644 --- a/web/src/pages/StrmPage.tsx +++ b/web/src/pages/StrmPage.tsx @@ -32,6 +32,7 @@ export function StrmPage() { overwrite={strm.generate.overwrite} includeLocal={strm.generate.includeLocal} preserveTree={strm.generate.preserveTree} + refreshLibrary={strm.generate.refreshLibrary} generating={strm.generate.generating} generateResult={strm.generate.generateResult} playbackStatus={strm.generate.playbackStatus} @@ -47,6 +48,7 @@ export function StrmPage() { setOverwrite={strm.generate.setOverwrite} setIncludeLocal={strm.generate.setIncludeLocal} setPreserveTree={strm.generate.setPreserveTree} + setRefreshLibrary={strm.generate.setRefreshLibrary} /> diff --git a/web/src/pages/StrmRepairSection.tsx b/web/src/pages/StrmRepairSection.tsx index f3d732c..ac9b280 100644 --- a/web/src/pages/StrmRepairSection.tsx +++ b/web/src/pages/StrmRepairSection.tsx @@ -9,12 +9,14 @@ export function StrmRepairSection({ baseURL, outputDir, repairing, + refreshLibrary, result, runningMode, onPreview, onRepair, setBaseURL, setOutputDir, + setRefreshLibrary, }: StrmRepairSectionProps) { return (
@@ -43,6 +45,10 @@ export function StrmRepairSection({ > 使用当前访问地址 +