diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go index 5a93f6a..7fd2d28 100644 --- a/internal/handler/routes_authenticated.go +++ b/internal/handler/routes_authenticated.go @@ -123,6 +123,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc * authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc)) authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc)) authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc)) + authed.POST("/strm/generate", middleware.AdminRequired(), generateSTRMHandler(svc)) // Duplicate finder. authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc)) diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 81554c8..9bf931e 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -96,3 +96,39 @@ func importSTRMHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusCreated, m) } } + +type generateSTRMReq struct { + LibraryID string `json:"library_id" binding:"required"` + OutputDir string `json:"output_dir"` + BaseURL string `json:"base_url"` + Enabled bool `json:"enabled"` + Overwrite bool `json:"overwrite"` + IncludeLocal bool `json:"include_local"` +} + +func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req generateSTRMReq + 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) + } + res, err := strmSvc.GenerateForLibrary(c.Request.Context(), service.GenerateSTRMOptions{ + LibraryID: req.LibraryID, + OutputDir: req.OutputDir, + BaseURL: req.BaseURL, + Enabled: req.Enabled, + Overwrite: req.Overwrite, + IncludeLocal: true, + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, res) + } +} diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 5039f69..a7eb10f 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -464,6 +464,7 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto "local_meta": res.LocalMetadata, "removed": res.Removed, }) + s.maybeGenerateSTRMAfterScan(lib.ID) // Online enrichment is opt-in. Local NFO is always consumed first during // the scan, and matched rows are excluded from EnrichLibrary's pending set. @@ -666,6 +667,7 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar "elapsed_seconds": int(time.Since(startedAt).Seconds()), "cloud": true, }) + s.maybeGenerateSTRMAfterScan(lib.ID) if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { go func(libID string) { if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil { @@ -844,6 +846,14 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, pat Container: strings.TrimPrefix(ext, "."), FileID: fileID, } + if ext == ".strm" { + m.Container = "strm" + if targetURL, err := readLocalSTRMTarget(path); err == nil && targetURL != "" { + m.STRMURL = targetURL + } else if err != nil { + s.log.Debug("read local strm failed", zap.String("path", path), zap.Error(err)) + } + } parsedSeason, parsedEpisode := ParseEpisode(path) m.SeasonNum = parsedSeason @@ -1055,6 +1065,31 @@ func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref st return "", nil } +func readLocalSTRMTarget(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(data), "\n") { + candidate := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) + if candidate == "" || strings.HasPrefix(candidate, "#") { + continue + } + if strings.HasPrefix(candidate, "/api/") || strings.HasPrefix(candidate, "/Videos/") || strings.HasPrefix(candidate, "/videos/") { + return candidate, nil + } + u, err := url.Parse(candidate) + if err != nil { + continue + } + switch strings.ToLower(u.Scheme) { + case "http", "https", "webdav", "davs", "alist", "alists", "openlist", "openlists": + return candidate, nil + } + } + return "", nil +} + func applyLocalMetadata(m *model.Media, local *LocalMetadata) { if local.Title != "" { m.Title = local.Title @@ -1138,3 +1173,23 @@ func (s *ScannerService) autoScrapeEnabled(ctx context.Context) bool { return false } } + +func (s *ScannerService) maybeGenerateSTRMAfterScan(libraryID string) { + if s == nil || s.repo == nil || s.repo.Setting == nil { + return + } + value, err := s.repo.Setting.Get(context.Background(), "strm.auto_generate_enabled") + if err != nil || !parseBoolSetting(value, false) { + return + } + go func() { + strmSvc := NewSTRMService(s.log, s.repo, s.cfg) + if _, err := strmSvc.GenerateForLibrary(context.Background(), GenerateSTRMOptions{ + LibraryID: libraryID, + Enabled: true, + IncludeLocal: true, + }); err != nil && s.log != nil { + s.log.Warn("auto generate strm failed", zap.String("library_id", libraryID), zap.Error(err)) + } + }() +} diff --git a/internal/service/scanner_incremental_test.go b/internal/service/scanner_incremental_test.go index d1a180f..4de3d65 100644 --- a/internal/service/scanner_incremental_test.go +++ b/internal/service/scanner_incremental_test.go @@ -68,6 +68,33 @@ func TestIngestPathAddsSingleFile(t *testing.T) { } } +func TestScanLibraryReadsLocalSTRMTarget(t *testing.T) { + sc, repos := newScannerTestEnv(t) + root := t.TempDir() + lib := model.Library{Name: "STRM", Path: root, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + strmPath := filepath.Join(root, "Cloud Movie.strm") + if err := os.WriteFile(strmPath, []byte("https://cdn.example.com/movie.mkv\n"), 0o644); err != nil { + t.Fatal(err) + } + res, err := sc.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan: %v", err) + } + if res.Added != 1 { + t.Fatalf("scan result = %#v, want added=1", res) + } + var media model.Media + if err := repos.DB.First(&media).Error; err != nil { + t.Fatal(err) + } + if media.Container != "strm" || media.STRMURL != "https://cdn.example.com/movie.mkv" { + t.Fatalf("strm media not parsed: %#v", media) + } +} + func TestRemovePathDeletesVanishedMedia(t *testing.T) { sc, repos := newScannerTestEnv(t) root := t.TempDir() diff --git a/internal/service/service.go b/internal/service/service.go index a285792..de1b60c 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -60,6 +60,7 @@ type Container struct { PlayProfiles *PlayProfileService Permissions *PermissionService StorageCfg *StorageConfigService + STRM *STRMService DownloadClients *DownloadClientService Assistant *AssistantService Organizer *OrganizerService @@ -114,6 +115,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont playProfiles := NewPlayProfileService(log, repos) permissions := NewPermissionService(log, repos) storageCfg := NewStorageConfigService(log, repos, crypto) + strmSvc := NewSTRMService(log, repos, cfg) scanner.SetStorageConfig(storageCfg) downloadClients := NewDownloadClientService(log, repos) assistant := NewAssistantService(log, repos, ai) @@ -207,6 +209,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont PlayProfiles: playProfiles, Permissions: permissions, StorageCfg: storageCfg, + STRM: strmSvc, DownloadClients: downloadClients, Assistant: assistant, Organizer: organizer, diff --git a/internal/service/strm_svc.go b/internal/service/strm_svc.go index 483ddbd..7fdfe78 100644 --- a/internal/service/strm_svc.go +++ b/internal/service/strm_svc.go @@ -7,6 +7,10 @@ import ( "fmt" "io" "net/http" + "net/url" + "os" + "path/filepath" + "strconv" "strings" "time" @@ -31,11 +35,270 @@ type STRMService struct { cfg *config.Config } +type GenerateSTRMOptions struct { + LibraryID string `json:"library_id"` + OutputDir string `json:"output_dir"` + BaseURL string `json:"base_url,omitempty"` + Enabled bool `json:"enabled"` + Overwrite bool `json:"overwrite"` + IncludeLocal bool `json:"include_local"` +} + +type GenerateSTRMResult struct { + LibraryID string `json:"library_id"` + OutputDir string `json:"output_dir"` + Generated int `json:"generated"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Errors []string `json:"errors,omitempty"` + Items []GenerateSTRMItem `json:"items,omitempty"` +} + +type GenerateSTRMItem struct { + MediaID string `json:"media_id"` + Title string `json:"title"` + FilePath string `json:"file_path"` + URL string `json:"url,omitempty"` + Action string `json:"action"` + Reason string `json:"reason,omitempty"` +} + // NewSTRMService 创建 STRM 服务。 func NewSTRMService(log *zap.Logger, repo *repository.Container, cfg *config.Config) *STRMService { return &STRMService{log: log, repo: repo, cfg: cfg} } +func (s *STRMService) GenerateForLibrary(ctx context.Context, opts GenerateSTRMOptions) (*GenerateSTRMResult, error) { + if s == nil || s.repo == nil || s.repo.DB == nil { + return nil, errors.New("strm service unavailable") + } + libraryID := strings.TrimSpace(opts.LibraryID) + if libraryID == "" { + return nil, errors.New("library_id required") + } + lib, err := s.repo.Library.FindByID(ctx, libraryID) + if err != nil { + return nil, err + } + if lib == nil { + return nil, errors.New("library not found") + } + outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)) + if (outputDir == "" || outputDir == ".") && s.repo.Setting != nil { + if saved, err := s.repo.Setting.Get(ctx, "strm.output_dir"); err == nil { + outputDir = resolveMappedDestinationPath(strings.TrimSpace(saved)) + } + } + if outputDir == "" || outputDir == "." { + outputDir = s.defaultOutputDir(lib) + } + if outputDir == "" || outputDir == "." { + return nil, errors.New("output_dir required") + } + if strings.TrimSpace(opts.BaseURL) != "" && s.repo.Setting != nil { + _ = s.repo.Setting.Set(ctx, "app.server_url", strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")) + } + if s.repo.Setting != nil { + _ = s.repo.Setting.Set(ctx, "strm.auto_generate_enabled", strconv.FormatBool(opts.Enabled)) + _ = s.repo.Setting.Set(ctx, "strm.output_dir", outputDir) + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return nil, err + } + + var rows []model.Media + if err := s.repo.DB.WithContext(ctx). + Where("library_id = ?", libraryID). + Order("title asc, season_num asc, episode_num asc, created_at asc"). + Find(&rows).Error; err != nil { + return nil, err + } + + res := &GenerateSTRMResult{LibraryID: libraryID, OutputDir: outputDir} + for _, media := range rows { + select { + case <-ctx.Done(): + return res, ctx.Err() + default: + } + item := s.generateOne(ctx, *lib, media, outputDir, opts) + res.Items = append(res.Items, item) + switch item.Action { + case "generated": + res.Generated++ + case "updated": + res.Updated++ + case "skipped": + res.Skipped++ + case "error": + res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", item.Title, item.Reason)) + } + } + return res, nil +} + +func (s *STRMService) defaultOutputDir(lib *model.Library) string { + if s != nil && s.cfg != nil && strings.TrimSpace(s.cfg.App.DataDir) != "" { + return filepath.Join(s.cfg.App.DataDir, "strm", sanitizeFilename(lib.Name)) + } + return filepath.Join("data", "strm", sanitizeFilename(lib.Name)) +} + +func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media model.Media, outputDir string, opts GenerateSTRMOptions) GenerateSTRMItem { + item := GenerateSTRMItem{MediaID: media.ID, Title: media.Title} + playURL := s.strmPlaybackURL(ctx, media, opts.BaseURL) + if playURL == "" { + item.Action = "skipped" + item.Reason = "no playable strm target" + return item + } + if strings.TrimSpace(media.STRMURL) == "" && !opts.IncludeLocal { + item.Action = "skipped" + item.Reason = "local media skipped" + return item + } + rel := s.strmRelativePath(lib, media) + if rel == "" { + item.Action = "skipped" + item.Reason = "cannot build file name" + return item + } + filePath := filepath.Join(outputDir, rel) + item.FilePath = filePath + item.URL = playURL + if _, err := os.Stat(filePath); err == nil && !opts.Overwrite { + item.Action = "skipped" + item.Reason = "target exists" + return item + } + action := "generated" + if _, err := os.Stat(filePath); err == nil { + action = "updated" + } + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + item.Action = "error" + item.Reason = err.Error() + return item + } + if err := os.WriteFile(filePath, []byte(playURL+"\n"), 0o644); err != nil { + item.Action = "error" + item.Reason = err.Error() + return item + } + _ = s.upsertGeneratedRecord(ctx, media, filePath, playURL, lib.Type) + item.Action = action + return item +} + +func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL string) string { + if raw := strings.TrimSpace(media.STRMURL); raw != "" { + return absolutizeSTRMURL(raw, firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg))) + } + if media.ID == "" { + return "" + } + return buildAbsoluteSTRMAPIURL(firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)), "/api/stream/"+url.PathEscape(media.ID), nil) +} + +func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) string { + title := strings.TrimSpace(media.Title) + if title == "" { + title = strings.TrimSuffix(filepath.Base(media.Path), filepath.Ext(media.Path)) + } + if title == "" { + return "" + } + seriesLike := isSeriesLibraryType(lib.Type) || media.SeasonNum > 0 || media.EpisodeNum > 0 + if seriesLike { + show := inferSeriesNameFromPath(media.Path) + if show == "" { + show = title + } + season := media.SeasonNum + if season <= 0 { + season = 1 + } + name := title + if media.EpisodeNum > 0 { + name = fmt.Sprintf("%s - S%02dE%02d", show, season, media.EpisodeNum) + } + return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)+".strm") + } + folder := title + if media.Year > 0 && !strings.Contains(folder, strconv.Itoa(media.Year)) { + folder = fmt.Sprintf("%s (%d)", folder, media.Year) + } + safe := sanitizeFilename(folder) + return filepath.Join(safe, safe+".strm") +} + +func (s *STRMService) upsertGeneratedRecord(ctx context.Context, media model.Media, filePath, playURL, mediaType string) error { + protocol := "" + if u, err := url.Parse(playURL); err == nil { + protocol = strings.ToLower(u.Scheme) + } + if protocol == "" { + protocol = "http" + } + record := model.STRMRecord{ + Title: media.Title, + URL: playURL, + FilePath: filePath, + Protocol: protocol, + MediaID: media.ID, + MediaType: mediaType, + SeasonNum: media.SeasonNum, + EpisodeNum: media.EpisodeNum, + } + var existing model.STRMRecord + err := s.repo.DB.WithContext(ctx).Where("media_id = ? AND file_path = ?", media.ID, filePath).First(&existing).Error + if err == nil { + existing.Title = record.Title + existing.URL = record.URL + existing.Protocol = record.Protocol + existing.MediaType = record.MediaType + existing.SeasonNum = record.SeasonNum + existing.EpisodeNum = record.EpisodeNum + return s.repo.DB.WithContext(ctx).Save(&existing).Error + } + return s.repo.DB.WithContext(ctx).Create(&record).Error +} + +func absolutizeSTRMURL(raw, baseURL string) string { + raw = strings.TrimSpace(raw) + if raw == "" || strings.HasPrefix(raw, "//") { + return raw + } + u, err := url.Parse(raw) + if err == nil && u.IsAbs() { + return raw + } + return buildAbsoluteSTRMAPIURL(baseURL, raw, nil) +} + +func buildAbsoluteSTRMAPIURL(baseURL, apiPath string, query url.Values) string { + apiPath = "/" + strings.TrimLeft(strings.TrimSpace(apiPath), "/") + if query != nil && len(query) > 0 { + apiPath += "?" + query.Encode() + } + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return apiPath + } + base, err := url.Parse(baseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return apiPath + } + target, err := url.Parse(apiPath) + if err != nil { + return apiPath + } + base.Path = strings.TrimRight(base.Path, "/") + "/" + strings.TrimLeft(target.Path, "/") + base.RawQuery = target.RawQuery + base.Fragment = "" + return base.String() +} + // Create 创建 STRM 记录。 func (s *STRMService) Create(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) { if err := s.validateSTRM(record); err != nil { diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go new file mode 100644 index 0000000..8e9b715 --- /dev/null +++ b/internal/service/strm_svc_test.go @@ -0,0 +1,91 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + rows := []model.Media{ + {Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}, + {Base: model.Base{ID: "local-media"}, LibraryID: lib.ID, Title: "本地电影", Year: 2025, Path: filepath.Join(t.TempDir(), "本地电影.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, &config.Config{}) + + res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ + LibraryID: lib.ID, + OutputDir: outDir, + BaseURL: "http://nas.example:18080", + IncludeLocal: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Generated != 2 || res.Skipped != 0 { + t.Fatalf("result = %#v, want generated=2 skipped=0", res) + } + cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm") + localSTRM := filepath.Join(outDir, "本地电影 (2025)", "本地电影 (2025).strm") + assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/cloud/play/openlist?ref=movie") + assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media") + + var count int64 + if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("strm record count = %d, want 2", count) + } + + res, err = svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ + LibraryID: lib.ID, + OutputDir: outDir, + BaseURL: "http://nas.example:18080", + IncludeLocal: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Skipped != 2 { + t.Fatalf("second run skipped = %d, want 2", res.Skipped) + } +} + +func assertFileContains(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(data)); got != want { + t.Fatalf("%s = %q, want %q", path, got, want) + } +} diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 313586e..2955825 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -1,9 +1,37 @@ import { api } from './client' +export type GenerateSTRMInput = { + library_id: string + output_dir?: string + base_url?: string + enabled?: boolean + overwrite?: boolean + include_local?: boolean +} + +export type GenerateSTRMResult = { + library_id: string + output_dir: string + generated: number + updated: number + skipped: number + errors?: string[] + items?: Array<{ + media_id: string + title: string + file_path: string + url?: string + action: string + reason?: string + }> +} + export const strmAPI = { set: (mediaID: string, url: string) => api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data), clear: (mediaID: string) => api.delete(`/media/${mediaID}/strm`).then((r) => r.data), importURL: (libraryID: string, title: string, url: string) => api.post('/strm/import', { library_id: libraryID, title, url }).then((r) => r.data), + generate: (input: GenerateSTRMInput) => + api.post('/strm/generate', input).then((r) => r.data), } diff --git a/web/src/pages/StrmPage.tsx b/web/src/pages/StrmPage.tsx index acd0dfa..0c03f1a 100644 --- a/web/src/pages/StrmPage.tsx +++ b/web/src/pages/StrmPage.tsx @@ -1,9 +1,10 @@ import { FormEvent, useEffect, useState } from 'react' -import { Link as LinkIcon, Plus, Search, Trash2 } from 'lucide-react' +import { Link as LinkIcon, Loader2, Plus, Search, Trash2, Wand2 } from 'lucide-react' import toast from 'react-hot-toast' +import { adminAPI } from '../api/admin' import { libraryAPI, mediaAPI } from '../api/library' -import { strmAPI } from '../api/strm' +import { strmAPI, type GenerateSTRMResult } from '../api/strm' import { confirmAction } from '../components/ConfirmDialog' import type { Library, Media } from '../types' @@ -16,6 +17,14 @@ import type { Library, Media } from '../types' export function StrmPage() { const [libraries, setLibraries] = useState([]) + // Auto-generate state + const [generateLibraryID, setGenerateLibraryID] = useState('') + const [baseURL, setBaseURL] = useState('') + const [outputDir, setOutputDir] = useState('') + const [overwrite, setOverwrite] = useState(false) + const [generating, setGenerating] = useState(false) + const [generateResult, setGenerateResult] = useState(null) + // Import form state const [libraryID, setLibraryID] = useState('') const [title, setTitle] = useState('') @@ -30,12 +39,51 @@ export function StrmPage() { useEffect(() => { libraryAPI.list().then(setLibraries).catch(() => undefined) + adminAPI + .listSettings() + .then((rows) => { + const settings = Object.fromEntries(rows.map((row) => [row.key, row.value])) + setBaseURL(settings['app.server_url'] || settings['strm.base_url'] || '') + setOutputDir(settings['strm.output_dir'] || '') + }) + .catch(() => undefined) }, []) // Default the import library to the first available one once loaded. useEffect(() => { if (!libraryID && libraries[0]) setLibraryID(libraries[0].id) - }, [libraries, libraryID]) + if (!generateLibraryID && libraries[0]) setGenerateLibraryID(libraries[0].id) + }, [libraries, libraryID, generateLibraryID]) + + const onGenerate = async (e: FormEvent) => { + e.preventDefault() + if (!generateLibraryID || !baseURL.trim()) return + if (!/^https?:\/\//i.test(baseURL.trim())) { + toast.error('域名必须以 http:// 或 https:// 开头') + return + } + setGenerating(true) + try { + const result = await strmAPI.generate({ + library_id: generateLibraryID, + base_url: baseURL.trim().replace(/\/+$/, ''), + output_dir: outputDir.trim(), + overwrite, + enabled: true, + include_local: true, + }) + setGenerateResult(result) + setOutputDir(result.output_dir || outputDir) + toast.success(`生成完成:新增 ${result.generated} · 更新 ${result.updated} · 跳过 ${result.skipped}`) + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '生成失败' + toast.error(msg) + } finally { + setGenerating(false) + } + } const onImport = async (e: FormEvent) => { e.preventDefault() @@ -129,6 +177,80 @@ export function StrmPage() { +
+
+
+

自动生成 STRM 文件

+

+ 只需要填写自己的访问域名,系统会按媒体库内每个媒体批量生成可播放的 .strm 文件。 +

+
+ + 开启后可 STRM 播放 + +
+
+ + setBaseURL(e.target.value)} + /> + + setOutputDir(e.target.value)} + /> + +
+

+ 生成内容为 域名 + /api/stream/媒体ID 或网盘 302 播放入口;域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。 +

+ {generateResult && ( +
+
+ 输出目录:{generateResult.output_dir} +
+
+ 新增 {generateResult.generated} · 更新 {generateResult.updated} · 跳过 {generateResult.skipped} +
+ {generateResult.errors && generateResult.errors.length > 0 && ( +
+ 失败 {generateResult.errors.length} 条:{generateResult.errors.slice(0, 3).join(';')} +
+ )} +
+ )} +
+ {/* Import a new STRM-only entry. */}

导入 STRM 条目