mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +08:00
implement automatic strm generation
This commit is contained in:
@@ -123,6 +123,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
|||||||
authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc))
|
authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc))
|
||||||
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
|
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
|
||||||
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
|
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
|
||||||
|
authed.POST("/strm/generate", middleware.AdminRequired(), generateSTRMHandler(svc))
|
||||||
|
|
||||||
// Duplicate finder.
|
// Duplicate finder.
|
||||||
authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc))
|
authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc))
|
||||||
|
|||||||
@@ -96,3 +96,39 @@ func importSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusCreated, m)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -464,6 +464,7 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
|
|||||||
"local_meta": res.LocalMetadata,
|
"local_meta": res.LocalMetadata,
|
||||||
"removed": res.Removed,
|
"removed": res.Removed,
|
||||||
})
|
})
|
||||||
|
s.maybeGenerateSTRMAfterScan(lib.ID)
|
||||||
|
|
||||||
// Online enrichment is opt-in. Local NFO is always consumed first during
|
// Online enrichment is opt-in. Local NFO is always consumed first during
|
||||||
// the scan, and matched rows are excluded from EnrichLibrary's pending set.
|
// 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()),
|
"elapsed_seconds": int(time.Since(startedAt).Seconds()),
|
||||||
"cloud": true,
|
"cloud": true,
|
||||||
})
|
})
|
||||||
|
s.maybeGenerateSTRMAfterScan(lib.ID)
|
||||||
if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
|
if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
|
||||||
go func(libID string) {
|
go func(libID string) {
|
||||||
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
|
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, "."),
|
Container: strings.TrimPrefix(ext, "."),
|
||||||
FileID: fileID,
|
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)
|
parsedSeason, parsedEpisode := ParseEpisode(path)
|
||||||
m.SeasonNum = parsedSeason
|
m.SeasonNum = parsedSeason
|
||||||
@@ -1055,6 +1065,31 @@ func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref st
|
|||||||
return "", nil
|
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) {
|
func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
||||||
if local.Title != "" {
|
if local.Title != "" {
|
||||||
m.Title = local.Title
|
m.Title = local.Title
|
||||||
@@ -1138,3 +1173,23 @@ func (s *ScannerService) autoScrapeEnabled(ctx context.Context) bool {
|
|||||||
return false
|
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))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestRemovePathDeletesVanishedMedia(t *testing.T) {
|
||||||
sc, repos := newScannerTestEnv(t)
|
sc, repos := newScannerTestEnv(t)
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type Container struct {
|
|||||||
PlayProfiles *PlayProfileService
|
PlayProfiles *PlayProfileService
|
||||||
Permissions *PermissionService
|
Permissions *PermissionService
|
||||||
StorageCfg *StorageConfigService
|
StorageCfg *StorageConfigService
|
||||||
|
STRM *STRMService
|
||||||
DownloadClients *DownloadClientService
|
DownloadClients *DownloadClientService
|
||||||
Assistant *AssistantService
|
Assistant *AssistantService
|
||||||
Organizer *OrganizerService
|
Organizer *OrganizerService
|
||||||
@@ -114,6 +115,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
playProfiles := NewPlayProfileService(log, repos)
|
playProfiles := NewPlayProfileService(log, repos)
|
||||||
permissions := NewPermissionService(log, repos)
|
permissions := NewPermissionService(log, repos)
|
||||||
storageCfg := NewStorageConfigService(log, repos, crypto)
|
storageCfg := NewStorageConfigService(log, repos, crypto)
|
||||||
|
strmSvc := NewSTRMService(log, repos, cfg)
|
||||||
scanner.SetStorageConfig(storageCfg)
|
scanner.SetStorageConfig(storageCfg)
|
||||||
downloadClients := NewDownloadClientService(log, repos)
|
downloadClients := NewDownloadClientService(log, repos)
|
||||||
assistant := NewAssistantService(log, repos, ai)
|
assistant := NewAssistantService(log, repos, ai)
|
||||||
@@ -207,6 +209,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
PlayProfiles: playProfiles,
|
PlayProfiles: playProfiles,
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
StorageCfg: storageCfg,
|
StorageCfg: storageCfg,
|
||||||
|
STRM: strmSvc,
|
||||||
DownloadClients: downloadClients,
|
DownloadClients: downloadClients,
|
||||||
Assistant: assistant,
|
Assistant: assistant,
|
||||||
Organizer: organizer,
|
Organizer: organizer,
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,11 +35,270 @@ type STRMService struct {
|
|||||||
cfg *config.Config
|
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 服务。
|
// NewSTRMService 创建 STRM 服务。
|
||||||
func NewSTRMService(log *zap.Logger, repo *repository.Container, cfg *config.Config) *STRMService {
|
func NewSTRMService(log *zap.Logger, repo *repository.Container, cfg *config.Config) *STRMService {
|
||||||
return &STRMService{log: log, repo: repo, cfg: cfg}
|
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 记录。
|
// Create 创建 STRM 记录。
|
||||||
func (s *STRMService) Create(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) {
|
func (s *STRMService) Create(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) {
|
||||||
if err := s.validateSTRM(record); err != nil {
|
if err := s.validateSTRM(record); err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,37 @@
|
|||||||
import { api } from './client'
|
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 = {
|
export const strmAPI = {
|
||||||
set: (mediaID: string, url: string) =>
|
set: (mediaID: string, url: string) =>
|
||||||
api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data),
|
api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data),
|
||||||
clear: (mediaID: string) => api.delete(`/media/${mediaID}/strm`).then((r) => r.data),
|
clear: (mediaID: string) => api.delete(`/media/${mediaID}/strm`).then((r) => r.data),
|
||||||
importURL: (libraryID: string, title: string, url: string) =>
|
importURL: (libraryID: string, title: string, url: string) =>
|
||||||
api.post('/strm/import', { library_id: libraryID, title, url }).then((r) => r.data),
|
api.post('/strm/import', { library_id: libraryID, title, url }).then((r) => r.data),
|
||||||
|
generate: (input: GenerateSTRMInput) =>
|
||||||
|
api.post<GenerateSTRMResult>('/strm/generate', input).then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-3
@@ -1,9 +1,10 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
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 toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import { adminAPI } from '../api/admin'
|
||||||
import { libraryAPI, mediaAPI } from '../api/library'
|
import { libraryAPI, mediaAPI } from '../api/library'
|
||||||
import { strmAPI } from '../api/strm'
|
import { strmAPI, type GenerateSTRMResult } from '../api/strm'
|
||||||
import { confirmAction } from '../components/ConfirmDialog'
|
import { confirmAction } from '../components/ConfirmDialog'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
|
|
||||||
@@ -16,6 +17,14 @@ import type { Library, Media } from '../types'
|
|||||||
export function StrmPage() {
|
export function StrmPage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
|
|
||||||
|
// 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<GenerateSTRMResult | null>(null)
|
||||||
|
|
||||||
// Import form state
|
// Import form state
|
||||||
const [libraryID, setLibraryID] = useState('')
|
const [libraryID, setLibraryID] = useState('')
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
@@ -30,12 +39,51 @@ export function StrmPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
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.
|
// Default the import library to the first available one once loaded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryID && libraries[0]) setLibraryID(libraries[0].id)
|
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) => {
|
const onImport = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -129,6 +177,80 @@ export function StrmPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="glass-panel space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-lg font-semibold text-ink-600">自动生成 STRM 文件</h2>
|
||||||
|
<p className="text-sm text-ink-50">
|
||||||
|
只需要填写自己的访问域名,系统会按媒体库内每个媒体批量生成可播放的 .strm 文件。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-emerald-300/40 bg-emerald-400/10 px-3 py-1 text-xs font-semibold text-emerald-500">
|
||||||
|
开启后可 STRM 播放
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onGenerate} className="grid gap-3 md:grid-cols-4">
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
value={generateLibraryID}
|
||||||
|
onChange={(e) => setGenerateLibraryID(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
选择媒体库
|
||||||
|
</option>
|
||||||
|
{libraries.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.name} ({l.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base md:col-span-2"
|
||||||
|
placeholder="http://NAS-IP:18080 或 https://media.example.com"
|
||||||
|
value={baseURL}
|
||||||
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={overwrite}
|
||||||
|
onChange={(e) => setOverwrite(e.target.checked)}
|
||||||
|
/>
|
||||||
|
覆盖已存在
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input-base md:col-span-3"
|
||||||
|
placeholder="输出目录可留空,默认写入 data/strm/媒体库名"
|
||||||
|
value={outputDir}
|
||||||
|
onChange={(e) => setOutputDir(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={generating || !generateLibraryID || !baseURL.trim()} className="neon-button">
|
||||||
|
{generating ? <Loader2 size={16} className="animate-spin" /> : <Wand2 size={16} />}
|
||||||
|
{generating ? '生成中…' : '批量生成 STRM'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-xs text-sand-500">
|
||||||
|
生成内容为 <code>域名 + /api/stream/媒体ID</code> 或网盘 302 播放入口;域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
|
||||||
|
</p>
|
||||||
|
{generateResult && (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 text-sm text-ink-50">
|
||||||
|
<div className="font-semibold text-ink-600">
|
||||||
|
输出目录:{generateResult.output_dir}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
新增 {generateResult.generated} · 更新 {generateResult.updated} · 跳过 {generateResult.skipped}
|
||||||
|
</div>
|
||||||
|
{generateResult.errors && generateResult.errors.length > 0 && (
|
||||||
|
<div className="mt-2 text-red-500">
|
||||||
|
失败 {generateResult.errors.length} 条:{generateResult.errors.slice(0, 3).join(';')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Import a new STRM-only entry. */}
|
{/* Import a new STRM-only entry. */}
|
||||||
<section className="glass-panel space-y-4">
|
<section className="glass-panel space-y-4">
|
||||||
<h2 className="font-display text-lg font-semibold text-ink-600">导入 STRM 条目</h2>
|
<h2 className="font-display text-lg font-semibold text-ink-600">导入 STRM 条目</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user