implement automatic strm generation

This commit is contained in:
ShukeBta
2026-06-10 21:36:55 +08:00
parent fd0428ee7d
commit 859d6e9d24
9 changed files with 629 additions and 3 deletions
+1
View File
@@ -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))
+36
View File
@@ -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)
}
}
+55
View File
@@ -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))
}
}()
}
@@ -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()
+3
View File
@@ -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,
+263
View File
@@ -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 {
+91
View File
@@ -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)
}
}