diff --git a/internal/handler/routes_authenticated_features.go b/internal/handler/routes_authenticated_features.go index c5baf0c..c4096f1 100644 --- a/internal/handler/routes_authenticated_features.go +++ b/internal/handler/routes_authenticated_features.go @@ -55,6 +55,7 @@ func registerAuthedSTRMRoutes(authed *gin.RouterGroup, svc *service.Container) { authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc)) authed.POST("/strm/generate", middleware.AdminRequired(), generateSTRMHandler(svc)) authed.POST("/strm/generate-from-tree", middleware.AdminRequired(), generateSTRMFromTreeHandler(svc)) + authed.POST("/strm/repair", middleware.AdminRequired(), repairSTRMHandler(svc)) } func registerAuthedDuplicateRoutes(authed *gin.RouterGroup, svc *service.Container) { diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 1fe1796..4731de8 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -123,6 +123,12 @@ type generateSTRMTreeReq struct { TransferSubtitles bool `json:"transfer_subtitles"` } +type repairSTRMReq struct { + OutputDir string `json:"output_dir" binding:"required"` + BaseURL string `json:"base_url"` + DryRun bool `json:"dry_run"` +} + func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req generateSTRMReq @@ -204,6 +210,34 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc { } } +func repairSTRMHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req repairSTRMReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + strmSvc := svc.STRM + if strmSvc == nil { + strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg) + } + baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") + if baseURL == "" { + baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") + } + res, err := strmSvc.RepairFiles(c.Request.Context(), service.RepairSTRMOptions{ + OutputDir: req.OutputDir, + BaseURL: baseURL, + DryRun: req.DryRun, + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, res) + } +} + 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_repair.go b/internal/service/strm_repair.go new file mode 100644 index 0000000..a8c0271 --- /dev/null +++ b/internal/service/strm_repair.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "errors" + "io/fs" + "net/url" + "os" + "path/filepath" + "strings" +) + +type RepairSTRMOptions struct { + OutputDir string `json:"output_dir"` + BaseURL string `json:"base_url,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +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"` +} + +type RepairSTRMItem struct { + FilePath string `json:"file_path"` + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` + Action string `json:"action"` + Reason string `json:"reason,omitempty"` +} + +func (s *STRMService) RepairFiles(ctx context.Context, opts RepairSTRMOptions) (*RepairSTRMResult, error) { + outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)) + if outputDir == "" || outputDir == "." { + return nil, errors.New("output_dir required") + } + info, err := os.Stat(outputDir) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, errors.New("output_dir must be a directory") + } + result := &RepairSTRMResult{OutputDir: outputDir} + baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") + err = filepath.WalkDir(outputDir, func(filePath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + result.Errors = append(result.Errors, filePath+": "+walkErr.Error()) + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strm") { + return nil + } + result.addRepairItem(repairSTRMFile(filePath, baseURL, opts.DryRun)) + return nil + }) + if err != nil { + return result, err + } + return result, nil +} + +func repairSTRMFile(filePath, baseURL string, dryRun bool) RepairSTRMItem { + item := RepairSTRMItem{FilePath: filePath} + body, err := os.ReadFile(filePath) // #nosec G304 -- admin-selected STRM repair output directory. + if err != nil { + item.Action = "error" + item.Reason = err.Error() + return item + } + before := strings.TrimSpace(string(body)) + after, ok := repairedSTRMPlaybackURL(before, baseURL) + if !ok { + item.Action = "skipped" + item.Reason = "unsupported strm target" + return item + } + if after == before { + item.Action = "skipped" + item.Reason = "already current" + return item + } + item.Before = before + item.After = after + if dryRun { + item.Action = "preview" + item.Reason = "repaired" + return item + } + if err := os.WriteFile(filePath, []byte(after+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are player-readable sidecars. + item.Action = "error" + item.Reason = err.Error() + return item + } + item.Action = "repaired" + return item +} + +func repairedSTRMPlaybackURL(raw, baseURL string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || strings.HasPrefix(raw, "//") { + return "", false + } + parsed, err := url.Parse(raw) + if err != nil || !strmRepairOwnsAPIPath(parsed.Path) { + return "", false + } + apiPath := parsed.EscapedPath() + if apiPath == "" { + apiPath = parsed.Path + } + if parsed.RawQuery != "" { + apiPath += "?" + parsed.RawQuery + } + return buildAbsoluteSTRMAPIURL(baseURL, apiPath, nil), true +} + +func strmRepairOwnsAPIPath(apiPath string) bool { + value := strings.ToLower(strings.TrimSpace(apiPath)) + return strings.HasPrefix(value, "/api/stream/") || strings.HasPrefix(value, "/api/cloud/play/") +} + +func (r *RepairSTRMResult) addRepairItem(item RepairSTRMItem) { + r.Items = append(r.Items, item) + switch item.Action { + case "repaired": + r.Repaired++ + case "preview": + r.Previewed++ + case "skipped": + r.Skipped++ + case "error": + r.Errors = append(r.Errors, item.FilePath+": "+item.Reason) + } +} diff --git a/internal/service/strm_repair_test.go b/internal/service/strm_repair_test.go new file mode 100644 index 0000000..b2daab4 --- /dev/null +++ b/internal/service/strm_repair_test.go @@ -0,0 +1,77 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap" +) + +func TestRepairSTRMFilesRewritesOwnedPlaybackURLs(t *testing.T) { + outDir := t.TempDir() + cloudPath := filepath.Join(outDir, "Movies", "A.strm") + streamPath := filepath.Join(outDir, "Shows", "S01E01.strm") + externalPath := filepath.Join(outDir, "External.strm") + for _, path := range []string{cloudPath, streamPath, externalPath} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(cloudPath, []byte("http://old.local/api/cloud/play/openlist?ref=%2FMovies%2FA.mkv\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(streamPath, []byte("/api/stream/media-1?token=old\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(externalPath, []byte("https://cdn.example.com/video.m3u8\n"), 0o644); err != nil { + t.Fatal(err) + } + + svc := NewSTRMService(zap.NewNop(), nil, nil) + res, err := svc.RepairFiles(t.Context(), RepairSTRMOptions{ + OutputDir: outDir, + BaseURL: "https://media.example.com", + }) + if err != nil { + t.Fatal(err) + } + if res.Repaired != 2 || res.Skipped != 1 || len(res.Errors) != 0 { + t.Fatalf("result = %#v, want two repaired and one skipped", res) + } + if got := readSTRM(t, cloudPath); got != "https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FA.mkv" { + t.Fatalf("cloud strm = %q", got) + } + if got := readSTRM(t, streamPath); got != "https://media.example.com/api/stream/media-1?token=old" { + t.Fatalf("stream strm = %q", got) + } + if got := readSTRM(t, externalPath); got != "https://cdn.example.com/video.m3u8" { + t.Fatalf("external strm should not change: %q", got) + } +} + +func TestRepairSTRMFilesDryRunDoesNotWrite(t *testing.T) { + outDir := t.TempDir() + filePath := filepath.Join(outDir, "Movie.strm") + original := "http://old.local/api/cloud/play/openlist?ref=%2FMovie.mkv\n" + if err := os.WriteFile(filePath, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + svc := NewSTRMService(zap.NewNop(), nil, nil) + + res, err := svc.RepairFiles(t.Context(), RepairSTRMOptions{ + OutputDir: outDir, + BaseURL: "https://media.example.com", + DryRun: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Previewed != 1 || res.Repaired != 0 || len(res.Items) != 1 { + t.Fatalf("result = %#v, want one repair preview", res) + } + if got := readSTRM(t, filePath); got != strings.TrimSpace(original) { + t.Fatalf("dry run changed file: %q", got) + } +} diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 1182cfb..779497f 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -49,6 +49,27 @@ export type GenerateSTRMTreeInput = { transfer_subtitles?: boolean } +export type RepairSTRMInput = { + output_dir: string + base_url?: string + dry_run?: boolean +} + +export type RepairSTRMResult = { + output_dir: string + repaired: number + previewed?: number + skipped: number + errors?: string[] + items?: Array<{ + file_path: string + before?: string + after?: string + action: string + reason?: string + }> +} + export const strmAPI = { set: (mediaID: string, url: string) => api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data), @@ -63,4 +84,8 @@ export const strmAPI = { api .post('/strm/generate-from-tree', input, { timeout: BATCH_REQUEST_TIMEOUT }) .then((r) => r.data), + repair: (input: RepairSTRMInput) => + api + .post('/strm/repair', input, { timeout: BATCH_REQUEST_TIMEOUT }) + .then((r) => r.data), } diff --git a/web/src/pages/StrmPage.tsx b/web/src/pages/StrmPage.tsx index 6b00fd8..60a707b 100644 --- a/web/src/pages/StrmPage.tsx +++ b/web/src/pages/StrmPage.tsx @@ -2,6 +2,7 @@ import { StrmAttachSection } from './StrmAttachSection' import { StrmGenerateSection } from './StrmGenerateSection' import { StrmImportSection } from './StrmImportSection' import { StrmPageHeader } from './StrmPageHeader' +import { StrmRepairSection } from './StrmRepairSection' import { StrmTreeGenerateSection } from './StrmTreeGenerateSection' import { useStrmPage } from './useStrmPage' @@ -50,6 +51,8 @@ export function StrmPage() { + + + +export function StrmRepairSection({ + baseURL, + outputDir, + repairing, + result, + runningMode, + onPreview, + onRepair, + setBaseURL, + setOutputDir, +}: StrmRepairSectionProps) { + return ( +
+
+

STRM 修复

+

批量修复已存在 .strm 文件中的本服务播放地址。

+
+
+ setOutputDir(e.target.value)} + /> + setBaseURL(e.target.value)} + /> + + + +
+ {result && ( +
+
输出目录:{result.output_dir}
+
+ {result.previewed ? `预检 ${result.previewed} · ` : ''} + 修复 {result.repaired} · 跳过 {result.skipped} +
+ {result.errors && result.errors.length > 0 && ( +
+ 失败 {result.errors.length} 条:{result.errors.slice(0, 3).join(';')} +
+ )} +
+ )} +
+ ) +} diff --git a/web/src/pages/useStrmPage.ts b/web/src/pages/useStrmPage.ts index 6266c67..95f0a92 100644 --- a/web/src/pages/useStrmPage.ts +++ b/web/src/pages/useStrmPage.ts @@ -5,12 +5,14 @@ import type { Library } from '../types' import { useStrmAttachForm } from './useStrmAttachForm' import { useStrmGenerateForm } from './useStrmGenerateForm' import { useStrmImportForm } from './useStrmImportForm' +import { useStrmRepairForm } from './useStrmRepairForm' import { useStrmTreeGenerateForm } from './useStrmTreeGenerateForm' export function useStrmPage() { const [libraries, setLibraries] = useState([]) const generate = useStrmGenerateForm(libraries) const treeGenerate = useStrmTreeGenerateForm() + const repair = useStrmRepairForm() const importForm = useStrmImportForm(libraries) const attach = useStrmAttachForm() @@ -23,6 +25,7 @@ export function useStrmPage() { generate, importForm, libraries, + repair, treeGenerate, } } diff --git a/web/src/pages/useStrmRepairForm.ts b/web/src/pages/useStrmRepairForm.ts new file mode 100644 index 0000000..1493dca --- /dev/null +++ b/web/src/pages/useStrmRepairForm.ts @@ -0,0 +1,65 @@ +import type { FormEvent } from 'react' +import { useState } from 'react' +import toast from 'react-hot-toast' + +import { strmAPI, type RepairSTRMResult } from '../api/strm' +import { currentOrigin } from './strmPageModel' +import { apiErrorMessage, isHTTPURL } from './strmPageUtils' + +export function useStrmRepairForm() { + const [baseURL, setBaseURL] = useState(currentOrigin()) + const [outputDir, setOutputDir] = useState('data/strm/tree') + const [runningMode, setRunningMode] = useState<'repair' | 'preview' | null>(null) + const [result, setResult] = useState(null) + + const runRepair = async (dryRun: boolean) => { + const trimmedBaseURL = baseURL.trim() + if (!outputDir.trim()) { + toast.error('请填写 STRM 输出目录') + return + } + if (trimmedBaseURL && !isHTTPURL(trimmedBaseURL)) { + toast.error('域名必须以 http:// 或 https:// 开头') + return + } + setRunningMode(dryRun ? 'preview' : 'repair') + try { + const next = await strmAPI.repair({ + output_dir: outputDir.trim(), + base_url: trimmedBaseURL.replace(/\/+$/, '') || undefined, + dry_run: dryRun, + }) + setResult(next) + if (dryRun) { + toast.success(`预检完成:可修复 ${next.previewed ?? 0} 个 · 跳过 ${next.skipped}`) + } else { + toast.success(`修复完成:修复 ${next.repaired} 个 · 跳过 ${next.skipped}`) + } + } catch (err: unknown) { + toast.error(apiErrorMessage(err, dryRun ? 'STRM 修复预检失败' : 'STRM 修复失败')) + } finally { + setRunningMode(null) + } + } + + const onRepair = async (event: FormEvent) => { + event.preventDefault() + await runRepair(false) + } + + const onPreview = async () => { + await runRepair(true) + } + + return { + baseURL, + outputDir, + repairing: runningMode !== null, + result, + runningMode, + onPreview, + onRepair, + setBaseURL, + setOutputDir, + } +}