fix docker compose paths and subscription robustness

This commit is contained in:
ShukeBta
2026-07-02 03:35:52 +08:00
parent 204f7fb676
commit a1687eb746
20 changed files with 463 additions and 121 deletions
+15 -6
View File
@@ -41,7 +41,8 @@ var discoverSectionCatalog = []discoverSectionDef{
{Key: "bangumi_calendar", Label: "Bangumi 每日放送", Provider: "bangumi"},
}
const discoverFeedSectionTimeout = 15 * time.Second
const discoverFeedSectionTimeout = 20 * time.Second
const discoverFeedBangumiTimeout = 30 * time.Second
const discoverFeedSlowSectionThreshold = 2 * time.Second
// discoverSectionsHandler returns the catalog of sections the UI can
@@ -85,14 +86,15 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
meta[k] = gin.H{"page": page, "has_next": false, "disabled": true}
continue
}
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), discoverFeedSectionTimeout)
sectionTimeout := discoverSectionTimeout(k)
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), sectionTimeout)
started := time.Now()
items, err := discoverSectionItems(sectionCtx, svc, k, page)
elapsed := time.Since(started)
cancel()
metaEntry := gin.H{"page": page, "has_next": false, "duration_ms": elapsed.Milliseconds()}
if err != nil {
logDiscoverFetchFailed(svc, k, page, elapsed, err)
logDiscoverFetchFailed(svc, k, page, elapsed, sectionTimeout, err)
if cached, ok := cachedDiscoverSection(svc, k, page); ok {
items = cached
metaEntry["stale"] = true
@@ -142,7 +144,7 @@ func fallbackDiscoverSectionItems(parent context.Context, svc *service.Container
if fallbackKey == "" || svc == nil || svc.Discover == nil {
return nil, "", false
}
ctx, cancel := context.WithTimeout(parent, discoverFeedSectionTimeout)
ctx, cancel := context.WithTimeout(parent, discoverSectionTimeout(fallbackKey))
defer cancel()
items, err := discoverSectionItems(ctx, svc, fallbackKey, page)
if err != nil || len(items) == 0 {
@@ -171,7 +173,7 @@ func fallbackDiscoverSectionKey(key string) string {
}
}
func logDiscoverFetchFailed(svc *service.Container, key string, page int, elapsed time.Duration, err error) {
func logDiscoverFetchFailed(svc *service.Container, key string, page int, elapsed, timeout time.Duration, err error) {
if svc == nil || svc.Log == nil || err == nil {
return
}
@@ -181,7 +183,7 @@ func logDiscoverFetchFailed(svc *service.Container, key string, page int, elapse
zap.Int("page", page),
zap.Duration("duration", elapsed),
zap.Int64("duration_ms", elapsed.Milliseconds()),
zap.Duration("timeout", discoverFeedSectionTimeout),
zap.Duration("timeout", timeout),
zap.Error(err))
}
@@ -231,6 +233,13 @@ func discoverFeedFallbackMessage(fallbackKey string, err error) string {
return "推荐源暂时不可用,已显示同类备用榜单"
}
func discoverSectionTimeout(key string) time.Duration {
if key == "bangumi_calendar" {
return discoverFeedBangumiTimeout
}
return discoverFeedSectionTimeout
}
func enabledDiscoverSections(ctx context.Context, svc *service.Container) []discoverSectionDef {
sections := make([]discoverSectionDef, 0, len(discoverSectionCatalog))
for _, section := range discoverSectionCatalog {
+10
View File
@@ -49,6 +49,7 @@ func TestDiscoverFetchFailureLogIncludesDiagnostics(t *testing.T) {
"tmdb_latest_movie",
2,
1500*time.Millisecond,
discoverSectionTimeout("tmdb_latest_movie"),
context.DeadlineExceeded,
)
@@ -71,6 +72,15 @@ func TestDiscoverFetchFailureLogIncludesDiagnostics(t *testing.T) {
}
}
func TestDiscoverSectionTimeoutRaisesBangumiBudget(t *testing.T) {
if got := discoverSectionTimeout("bangumi_calendar"); got != discoverFeedBangumiTimeout {
t.Fatalf("bangumi timeout = %s, want %s", got, discoverFeedBangumiTimeout)
}
if got := discoverSectionTimeout("tmdb_latest_movie"); got != discoverFeedSectionTimeout {
t.Fatalf("tmdb timeout = %s, want %s", got, discoverFeedSectionTimeout)
}
}
func TestDiscoverSlowFetchLogIncludesSectionTiming(t *testing.T) {
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
+70
View File
@@ -188,6 +188,76 @@ func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T)
}
}
func TestAddDownloadSelectsFirstEnabledQBitWhenDefaultMissing(t *testing.T) {
var firstAddCalls int32
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
if atomic.LoadInt32(&firstAddCalls) > 0 {
_, _ = w.Write([]byte(`[{"hash":"abc123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`))
return
}
_, _ = w.Write([]byte(`[]`))
case "/api/v2/torrents/add":
atomic.AddInt32(&firstAddCalls, 1)
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer first.Close()
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
_, _ = w.Write([]byte(`[]`))
case "/api/v2/torrents/add":
t.Fatal("second qB should not be selected before first enabled qB")
default:
http.NotFound(w, r)
}
}))
defer second.Close()
db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{})
repos := repository.New(db)
if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil {
t.Fatal(err)
}
firstClient := &model.DownloadClient{Name: "qB first", Type: "qbittorrent", Host: first.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true}
secondClient := &model.DownloadClient{Name: "qB second", Type: "qbittorrent", Host: second.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), firstClient); err != nil {
t.Fatal(err)
}
if err := repos.DownloadClient.Create(t.Context(), secondClient); err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{
Title: "Movie 2026 1080p",
})
if err != nil {
t.Fatal(err)
}
if task == nil {
t.Fatal("expected task")
}
if got := atomic.LoadInt32(&firstAddCalls); got != 1 {
t.Fatalf("first qb add calls = %d, want 1", got)
}
refreshed, err := repos.DownloadClient.FindByID(t.Context(), firstClient.ID)
if err != nil {
t.Fatal(err)
}
if refreshed == nil || !refreshed.IsDefault {
t.Fatalf("first enabled qB should be persisted as default, got %#v", refreshed)
}
}
func TestReloadConfigManagedModeDoesNotFallbackToLegacyWithoutRows(t *testing.T) {
var addCalls int32
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+11 -5
View File
@@ -4,6 +4,8 @@ import (
"context"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
@@ -33,10 +35,16 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
cfg.BaseURL = strings.TrimRight(c.Host, "/")
cfg.Username = c.Username
cfg.Password = c.Password
} else if c, err := d.soleEnabledQBitClient(ctx); err == nil && c != nil {
} else if c, err := d.preferredEnabledQBitClient(ctx); err == nil && c != nil {
cfg.BaseURL = strings.TrimRight(c.Host, "/")
cfg.Username = c.Username
cfg.Password = c.Password
_ = d.repo.DownloadClient.SetDefault(ctx, c.ID)
if d.log != nil {
d.log.Warn("default downloader missing; selected first enabled qbittorrent client",
zap.String("client_id", c.ID),
zap.String("client", c.Name))
}
}
}
if d.repo.Setting != nil {
@@ -62,7 +70,7 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
return nil
}
func (d *DownloadService) soleEnabledQBitClient(ctx context.Context) (*model.DownloadClient, error) {
func (d *DownloadService) preferredEnabledQBitClient(ctx context.Context) (*model.DownloadClient, error) {
if d == nil || d.repo == nil || d.repo.DownloadClient == nil {
return nil, nil
}
@@ -75,11 +83,9 @@ func (d *DownloadService) soleEnabledQBitClient(ctx context.Context) (*model.Dow
if rows[i].Type != "qbittorrent" {
continue
}
if selected != nil {
return nil, nil
}
row := rows[i]
selected = &row
break
}
return selected, nil
}
+13 -8
View File
@@ -108,6 +108,17 @@ func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
return helper.NewSiteHTTPClient(secs, cfg.UseProxy)
}
func siteRequestHTTPClient(client *http.Client, cfg SiteConfig) *http.Client {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
if client == nil || cfg.UseProxy || client.Timeout != timeout {
return newHTTPClient(cfg, timeout)
}
return client
}
// buildRequest 构建带认证的 HTTP 请求。
func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
@@ -178,10 +189,7 @@ func doRequest(ctx context.Context, client *http.Client, method, rawURL string,
// 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY
// 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。
httpClient := client
if cfg.UseProxy {
httpClient = newHTTPClient(cfg, cfg.Timeout)
}
httpClient := siteRequestHTTPClient(client, cfg)
resp, err := httpClient.Do(req)
if err != nil {
@@ -210,10 +218,7 @@ func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL stri
req.ContentLength = int64(len(body))
}
httpClient := client
if cfg.UseProxy {
httpClient = newHTTPClient(cfg, cfg.Timeout)
}
httpClient := siteRequestHTTPClient(client, cfg)
resp, err := httpClient.Do(req)
if err != nil {
return nil, 0, err
+29 -5
View File
@@ -4,6 +4,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -40,7 +41,7 @@ func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}`
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
if err != nil {
return fmt.Errorf("authenticate: %w", err)
return mteamRequestError("authenticate", cfg, err)
}
preview := string(data)
if len(preview) > 400 {
@@ -87,7 +88,7 @@ func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword strin
u := cfg.URL + "/api/torrent/search"
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
return nil, mteamRequestError("search", cfg, err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("search failed: status %d", status)
@@ -116,7 +117,7 @@ func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category stri
u := cfg.URL + "/api/torrent/search"
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
if err != nil {
return nil, fmt.Errorf("browse: %w", err)
return nil, mteamRequestError("browse", cfg, err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("browse failed: status %d", status)
@@ -132,7 +133,7 @@ func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string)
u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id)
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil)
if err != nil {
return nil, fmt.Errorf("detail: %w", err)
return nil, mteamRequestError("detail", cfg, err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("detail failed: status %d", status)
@@ -203,7 +204,7 @@ func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id st
// genDlToken 是 POST 但参数走 query string;body 留空。
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
if err != nil {
return "", fmt.Errorf("genDlToken: %w", err)
return "", mteamRequestError("genDlToken", cfg, err)
}
if status >= 300 {
return "", fmt.Errorf("genDlToken: HTTP %d", status)
@@ -232,3 +233,26 @@ func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id st
}
return dl, nil
}
func mteamRequestError(action string, cfg SiteConfig, err error) error {
if err == nil {
return nil
}
if isSiteRequestTimeout(err) {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
return fmt.Errorf("%s: M-Team API request timed out after %s; check Docker/IPv6/proxy access to api.m-team.cc or increase the site timeout to 45-60s: %w",
action, timeout.Round(time.Second), err)
}
return fmt.Errorf("%s: %w", action, err)
}
func isSiteRequestTimeout(err error) bool {
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var timeout interface{ Timeout() bool }
return errors.As(err, &timeout) && timeout.Timeout()
}
+42
View File
@@ -82,6 +82,48 @@ func TestMTeamAuthenticateReportsAPIMessage(t *testing.T) {
}
}
func TestMTeamAuthenticateHonorsConfiguredTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
}))
defer server.Close()
adapter := NewMTeamAdapter()
started := time.Now()
err := adapter.Authenticate(context.Background(), SiteConfig{
URL: server.URL,
AuthType: "api_key",
APIKey: "token-123",
Timeout: time.Second,
})
if err == nil {
t.Fatal("Authenticate error = nil, want timeout")
}
if elapsed := time.Since(started); elapsed >= 1500*time.Millisecond {
t.Fatalf("Authenticate elapsed = %s, want configured timeout to stop before upstream response", elapsed)
}
if !strings.Contains(err.Error(), "M-Team API request timed out") {
t.Fatalf("Authenticate error = %v, want M-Team timeout hint", err)
}
}
func TestAPISiteDefaultTimeoutIsRaised(t *testing.T) {
if got := siteRequestTimeout("mteam", 15); got != 45*time.Second {
t.Fatalf("mteam timeout = %s, want 45s", got)
}
if got := siteRequestTimeout("yemapt", 0); got != 45*time.Second {
t.Fatalf("yemapt timeout = %s, want 45s", got)
}
if got := siteRequestTimeout("nexusphp", 15); got != 15*time.Second {
t.Fatalf("nexusphp timeout = %s, want 15s", got)
}
if got := siteRequestTimeout("mteam", 60); got != 60*time.Second {
t.Fatalf("custom mteam timeout = %s, want 60s", got)
}
}
func TestYemaPTAuthenticateUsesAuthorizationHeader(t *testing.T) {
var gotPath string
var gotAuth string
+2 -6
View File
@@ -28,18 +28,13 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
return false, "site not found", err
}
// Get timeout from site config (default 15 seconds)
timeout := site.Timeout
if timeout <= 0 {
timeout = 15
}
flareSolverrURL := s.flareSolverrURL
// ── Path 1: site-aware adapter Authenticate ────────────────────────
// custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。
if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" {
cfg := s.siteModelToConfig(site)
actx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
actx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
if authErr := adapter.Authenticate(actx, cfg); authErr == nil {
now := time.Now()
@@ -74,6 +69,7 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
}
// ── Path 2: generic GET with browser headers / FlareSolverr ───────
timeout := int(siteRequestTimeout(site.Type, site.Timeout).Seconds())
ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log)
if err != nil {
now := time.Now()
+21 -4
View File
@@ -104,10 +104,7 @@ func (s *SiteService) Delete(ctx context.Context, id string) error {
// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。
// 当全局 FlareSolverr 已启用且此站点开启了 BrowserEmulation 时,填充 FlareSolverrURL。
func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig {
timeout := time.Duration(s.Timeout) * time.Second
if timeout <= 0 {
timeout = 15 * time.Second
}
timeout := siteRequestTimeout(s.Type, s.Timeout)
userAgent := s.UserAgent
if userAgent == "" {
userAgent = model.DefaultUserAgent
@@ -142,3 +139,23 @@ func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig {
rateLimiter: svc.apiRateLimiter,
}
}
func siteRequestTimeout(siteType string, timeoutSeconds int) time.Duration {
timeout := time.Duration(timeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 15 * time.Second
}
if isAPISiteType(siteType) && timeout <= 15*time.Second {
return 45 * time.Second
}
return timeout
}
func isAPISiteType(siteType string) bool {
switch strings.ToLower(strings.TrimSpace(siteType)) {
case "mteam", "yemapt":
return true
default:
return false
}
}
+2 -11
View File
@@ -7,7 +7,6 @@ import (
"sort"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -74,12 +73,7 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
}
cfg := s.siteModelToConfig(&site)
// Use site timeout or default 30s
timeout := time.Duration(site.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
timeout := cfg.Timeout
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
@@ -159,10 +153,7 @@ func (s *SiteService) SearchSite(ctx context.Context, siteID, keyword string, pa
return nil, fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type)
}
cfg := s.siteModelToConfig(site)
timeout := time.Duration(site.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
timeout := cfg.Timeout
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
+26 -3
View File
@@ -104,7 +104,7 @@ func (s *STRMService) GenerateForAllLibraries(ctx context.Context, opts Generate
if err != nil {
return nil, err
}
baseOutputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))
baseOutputDir := s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)))
result := &GenerateSTRMResult{LibraryID: "*", OutputDir: baseOutputDir}
for _, lib := range libraries {
select {
@@ -135,10 +135,10 @@ func (s *STRMService) GenerateForAllLibraries(ctx context.Context, opts Generate
}
func (s *STRMService) resolveSTRMOutputDir(ctx context.Context, lib *model.Library, opts GenerateSTRMOptions) string {
outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))
outputDir := s.remapLegacySTRMOutputDir(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))
outputDir = s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(saved)))
}
}
if outputDir == "" || outputDir == "." {
@@ -147,6 +147,29 @@ func (s *STRMService) resolveSTRMOutputDir(ctx context.Context, lib *model.Libra
return strmLibrarySpecificOutputDir(outputDir, lib)
}
func (s *STRMService) remapLegacySTRMOutputDir(outputDir string) string {
outputDir = filepath.Clean(strings.TrimSpace(outputDir))
if outputDir == "" || outputDir == "." || s == nil || s.cfg == nil {
return outputDir
}
dataDir := filepath.Clean(strings.TrimSpace(s.cfg.App.DataDir))
if dataDir == "" || dataDir == "." || sameLibraryPath(dataDir, "/app/data") {
return outputDir
}
legacy := "/app/data"
normalized := strings.TrimRight(cleanPathForVolumeMapping(outputDir), "/")
if sameLibraryPath(normalized, legacy) {
return dataDir
}
if strings.HasPrefix(strings.ToLower(normalized), legacy+"/") {
rel := strings.TrimPrefix(normalized[len(legacy):], "/")
if rel != "" {
return filepath.Join(dataDir, filepath.FromSlash(rel))
}
}
return outputDir
}
func (s *STRMService) saveSTRMGenerationSettings(ctx context.Context, outputDir string, opts GenerateSTRMOptions) {
if opts.SkipSettingsSave {
return
+42
View File
@@ -282,6 +282,48 @@ func TestGenerateSTRMForLibraryUsesCategoryDefaultOutputDir(t *testing.T) {
assertFileContains(t, filepath.Join(wantDir, "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token")
}
func TestGenerateSTRMRemapsLegacyAppDataOutputDir(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
repos := repository.New(db)
dataDir := t.TempDir()
lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := 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",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), "strm.output_dir", "/app/data/strm"); err != nil {
t.Fatal(err)
}
svc := NewSTRMService(zap.NewNop(), repos, &config.Config{App: config.AppConfig{DataDir: dataDir}})
res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
LibraryID: lib.ID,
BaseURL: "http://nas.example:18080",
PlaybackToken: "strm-token",
})
if err != nil {
t.Fatal(err)
}
wantDir := filepath.Join(dataDir, "strm", "电影")
if res.OutputDir != wantDir {
t.Fatalf("output dir = %q, want %q", res.OutputDir, wantDir)
}
if got, err := repos.Setting.Get(t.Context(), "strm.output_dir"); err != nil || got != wantDir {
t.Fatalf("saved strm.output_dir = %q, %v; want %q", got, err, wantDir)
}
assertFileContains(t, filepath.Join(wantDir, "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token")
}
func TestGenerateSTRMForLibraryUsesPathEpisodeFallback(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
repos := repository.New(db)