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
+14
View File
@@ -329,6 +329,20 @@ environment:
不要删除 `./postgres`。PostgreSQL 已经是主数据库,删除它会丢失账号、媒体库、订阅、配置和历史数据。
## 日志与 STRM 路径
Compose 模板默认把完整应用日志写入 `./data/logs/app.log`,同时拆分 `./data/logs/warn.log` 和 `./data/logs/error.log`。Docker 自身日志也会保留 10 个 50MB 文件:
```bash
docker compose logs -f mediastation-go
tail -f ./data/logs/app.log
tail -f ./data/logs/error.log
```
如果要排查订阅、站点搜索、自动整理或 STRM 生成问题,保持 `MEDIASTATION_LOGGING_LEVEL: info`;需要更细日志时临时改成 `debug`,确认后再改回 `info`。
STRM 输出目录请使用容器内可写路径,例如 `/data/strm`,或你已经挂载进容器的媒体目录。旧版本保存过 `/app/data/strm` 的部署会在生成时自动迁移到当前 `MEDIASTATION_APP_DATA_DIR`,默认就是 `/data`。
## 更新与备份
更新镜像:
+25 -8
View File
@@ -306,10 +306,20 @@ services:
- ./data:/data
- ./cache:/cache
# Beginners can keep ./media and ./downloads.
# NAS users should replace the left side with real absolute paths.
- ./media:/media
- ./downloads:/downloads
# Beginners can create ./media and ./downloads.
# NAS users should replace source with real absolute paths.
# create_host_path=false prevents Docker from silently creating an empty
# folder when the host path is wrong.
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
environment:
TZ: Asia/Shanghai
@@ -329,11 +339,12 @@ services:
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
MEDIASTATION_CACHE_CACHE_DIR: /cache
# If you changed ./media or ./downloads above,
# set these to the same real host paths.
MEDIASTATION_MEDIA_DIR: ./media
# Use /media and /downloads in the web UI and downloader by default.
# Only set MEDIASTATION_*_DIR to real host paths when migrating old
# libraries/tasks that already stored host paths.
MEDIASTATION_MEDIA_DIR: /media
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
MEDIASTATION_DOWNLOAD_DIR: ./downloads
MEDIASTATION_DOWNLOAD_DIR: /downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
postgres:
@@ -398,8 +409,14 @@ docker compose up -d --no-deps mediastation-go
```bash
docker compose logs -f mediastation-go
tail -f ./data/logs/app.log
tail -f ./data/logs/error.log
```
The compose templates keep full application logs in `./data/logs/app.log` and split warnings/errors into `warn.log` and `error.log`. Keep `MEDIASTATION_LOGGING_LEVEL=info` while diagnosing subscription, site search, organizer, or STRM generation issues; temporarily switch to `debug` only when deeper tracing is needed.
Use a writable container path for STRM output, such as `/data/strm` or a mounted media path. Deployments that previously saved `/app/data/strm` are migrated automatically to the configured `MEDIASTATION_APP_DATA_DIR`, which defaults to `/data`.
### Backup
For the default PostgreSQL deployment, back up:
+32 -18
View File
@@ -50,14 +50,23 @@ services:
- ./cache:/cache
# 媒体库目录。自动整理/重命名/入库需要读写权限。
# NAS 示例:- /vol1/1000/Media:/media
# Windows Docker Desktop 示例:- D:/Media:/media
- ./media:/media
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。
# NAS 示例:- /vol1/1000/Downloads:/downloads
# Windows Docker Desktop 示例:- D:/Downloads:/downloads
- ./downloads:/downloads
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释。
@@ -73,9 +82,12 @@ services:
MEDIASTATION_APP_PORT: 8080
MEDIASTATION_APP_WEB_DIR: /app/web/dist
MEDIASTATION_APP_DATA_DIR: /data
MEDIASTATION_LOGGING_LEVEL: warn
MEDIASTATION_LOGGING_LEVEL: info
MEDIASTATION_LOGGING_FORMAT: console
MEDIASTATION_LOGGING_OUTPUT_PATH: /data/logs
MEDIASTATION_LOGGING_MAX_SIZE_MB: "50"
MEDIASTATION_LOGGING_MAX_BACKUPS: "20"
MEDIASTATION_LOGGING_MAX_AGE_DAYS: "30"
MEDIASTATION_DATABASE_TYPE: postgres
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
@@ -91,10 +103,12 @@ services:
MEDIASTATION_UPDATE_IMAGE: ghcr.io/shukebta/mediastation-go:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MEDIASTATION_MEDIA_DIR: ./media
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
MEDIASTATION_MEDIA_DIR: /media
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
MEDIASTATION_DOWNLOAD_DIR: ./downloads
MEDIASTATION_DOWNLOAD_DIR: /downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
MEDIASTATION_TRANSCODER_ENABLED: "true"
@@ -114,8 +128,8 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
postgres:
image: postgres:16-alpine
@@ -137,8 +151,8 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
redis:
image: redis:7-alpine
@@ -162,8 +176,8 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
opensearch:
image: opensearchproject/opensearch:2
@@ -186,5 +200,5 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
+31 -10
View File
@@ -33,14 +33,23 @@ services:
- ./cache:/cache
# 媒体库。网页里添加媒体库时填写 /media 或 /media/子目录。
# NAS 示例:- /vol1/1000/Media:/media
# Windows Docker Desktop 示例:- D:/Media:/media
- ./media:/media
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。qBittorrent / Transmission 的保存目录建议也对齐到 /downloads。
# NAS 示例:- /vol1/1000/Downloads:/downloads
# Windows Docker Desktop 示例:- D:/Downloads:/downloads
- ./downloads:/downloads
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 可选:Intel 核显硬解/转码。需要时取消注释,并在后台开启硬件加速。
# - /dev/dri:/dev/dri
@@ -55,8 +64,15 @@ services:
PUID: "1000"
PGID: "1000"
MEDIASTATION_APP_HOST: 0.0.0.0
MEDIASTATION_APP_PORT: 8080
MEDIASTATION_APP_WEB_DIR: /app/web/dist
MEDIASTATION_APP_DATA_DIR: /data
# 单镜像档固定使用 SQLite。主数据库文件:./data/mediastation.db。
MEDIASTATION_DATABASE_TYPE: sqlite
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
MEDIASTATION_CACHE_CACHE_DIR: /cache
# 路径映射保持容器内统一,网页和下载器里优先使用 /media、/downloads。
MEDIASTATION_MEDIA_DIR: /media
@@ -64,8 +80,13 @@ services:
MEDIASTATION_DOWNLOAD_DIR: /downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
# 需要排查问题时可临时改成 info 或 debug。
MEDIASTATION_LOGGING_LEVEL: warn
# 完整应用日志默认写入 ./data/logs/app.log;排查复杂问题时可临时改成 debug。
MEDIASTATION_LOGGING_LEVEL: info
MEDIASTATION_LOGGING_FORMAT: console
MEDIASTATION_LOGGING_OUTPUT_PATH: /data/logs
MEDIASTATION_LOGGING_MAX_SIZE_MB: "50"
MEDIASTATION_LOGGING_MAX_BACKUPS: "20"
MEDIASTATION_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
@@ -81,5 +102,5 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
+26 -14
View File
@@ -45,18 +45,27 @@ services:
- ./cache:/cache
# 媒体库目录。自动整理/重命名/入库需要读写权限。
# NAS 示例:- /vol1/1000/Media:/media
# Windows Docker Desktop 示例:- D:/Media:/media
- /media
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。
# NAS 示例:- /vol1/1000/Downloads
# Windows Docker Desktop 示例:- D:/Downloads
- /downloads
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释。
# - /var/run/docker.sock
# - /var/run/docker.sock:/var/run/docker.sock
environment:
TZ: Asia/Shanghai
@@ -68,9 +77,12 @@ services:
MEDIASTATION_APP_PORT: 8080
MEDIASTATION_APP_WEB_DIR: /app/web/dist
MEDIASTATION_APP_DATA_DIR: /data
MEDIASTATION_LOGGING_LEVEL: warn
MEDIASTATION_LOGGING_LEVEL: info
MEDIASTATION_LOGGING_FORMAT: console
MEDIASTATION_LOGGING_OUTPUT_PATH: /data/logs
MEDIASTATION_LOGGING_MAX_SIZE_MB: "50"
MEDIASTATION_LOGGING_MAX_BACKUPS: "20"
MEDIASTATION_LOGGING_MAX_AGE_DAYS: "30"
MEDIASTATION_DATABASE_TYPE: postgres
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
@@ -105,8 +117,8 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
postgres:
image: postgres:16-alpine
@@ -128,8 +140,8 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
redis:
image: redis:7-alpine
@@ -153,5 +165,5 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
+32 -19
View File
@@ -49,20 +49,29 @@ services:
# 媒体库目录。
# 如果要使用自动整理/重命名/入库,这里必须保持读写,不能加 :ro。
# 只有完全不整理、只扫描/播放已有媒体时,才建议手动改成只读。
# 新手可先把影片放到当前目录的 ./media。
# 新手可先创建当前目录的 ./media 并把影片放进去。
# NAS 用户把左边改成真实路径,例如:
# - /vol1/1000/Media:/media
# source: /vol1/1000/Media
# Windows Docker Desktop 示例:
# - D:/Media:/media
- ./media:/media
# source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。
# qB 下载目录、手动整理、自动整理会经常用到。
# NAS 示例:
# - /vol1/1000/Downloads:/downloads
# source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:
# - D:/Downloads:/downloads
- ./downloads:/downloads
# source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释;如果提示权限不足,
@@ -82,11 +91,14 @@ services:
MEDIASTATION_APP_PORT: 8080
MEDIASTATION_APP_WEB_DIR: /app/web/dist
MEDIASTATION_APP_DATA_DIR: /data
# 需要排查订阅/站点搜索/下载器问题时,临时把 level 改成 info 或 debug。
# 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。
MEDIASTATION_LOGGING_LEVEL: warn
# 排查复杂问题时可临时改成 debug。
MEDIASTATION_LOGGING_LEVEL: info
MEDIASTATION_LOGGING_FORMAT: console
MEDIASTATION_LOGGING_OUTPUT_PATH: /data/logs
MEDIASTATION_LOGGING_MAX_SIZE_MB: "50"
MEDIASTATION_LOGGING_MAX_BACKUPS: "20"
MEDIASTATION_LOGGING_MAX_AGE_DAYS: "30"
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
# 旧版 ./data/mediastation.db 存在时,首次启动会自动迁移到 PostgreSQL。
@@ -103,14 +115,15 @@ services:
MEDIASTATION_UPDATE_IMAGE: ghcr.io/shukebta/mediastation-go:latest
# 路径换算配置。
# 如果上面 volumes 的 ./media 改成 /vol1/1000/Media,
# 这里也要改成同一个宿主机真实路径。
MEDIASTATION_MEDIA_DIR: ./media
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
MEDIASTATION_MEDIA_DIR: /media
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
# 如果上面 volumes 的 ./downloads 改成 /vol1/1000/Downloads,
# 这里也要改成同一个宿主机真实路径。
MEDIASTATION_DOWNLOAD_DIR: ./downloads
# 默认推荐下载器保存路径使用 /downloads。
# 如果下载器只能返回宿主机路径,再改成同一个宿主机真实路径。
MEDIASTATION_DOWNLOAD_DIR: /downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
# NAS 友好的低负载默认值。
@@ -132,8 +145,8 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
postgres:
image: postgres:16-alpine
@@ -157,5 +170,5 @@ services:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "10"
+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)
+9 -1
View File
@@ -6,6 +6,9 @@ import type { Site } from "../types";
import { confirmAction } from "../components/confirmAction";
import {
API_SITE_TIMEOUT_SECONDS,
API_SITE_TYPES,
DEFAULT_SITE_TIMEOUT_SECONDS,
defaultSiteForm,
siteFormToPayload,
siteToForm,
@@ -145,10 +148,15 @@ export function SitesPage() {
};
const handleTypeChange = (type: string) => {
const isAPISite = API_SITE_TYPES.has(type);
setForm((current) => ({
...current,
type,
auth_type: type === "mteam" || type === "yemapt" ? "api_key" : current.auth_type,
auth_type: isAPISite ? "api_key" : current.auth_type,
timeout:
isAPISite && current.timeout === DEFAULT_SITE_TIMEOUT_SECONDS
? API_SITE_TIMEOUT_SECONDS
: current.timeout,
}));
};
+11 -3
View File
@@ -37,6 +37,10 @@ export const AUTH_TYPE_LABELS: Record<string, string> = {
auth_header: "Auth Header",
};
export const DEFAULT_SITE_TIMEOUT_SECONDS = 15;
export const API_SITE_TIMEOUT_SECONDS = 45;
export const API_SITE_TYPES = new Set(["mteam", "yemapt"]);
// ── 默认表单 ──
export const defaultSiteForm = () => ({
name: "",
@@ -52,7 +56,7 @@ export const defaultSiteForm = () => ({
// 高级设置
user_agent: "",
rss_url: "",
timeout: 15,
timeout: DEFAULT_SITE_TIMEOUT_SECONDS,
priority: 50,
use_proxy: false,
rate_limit: false,
@@ -76,7 +80,7 @@ export function siteToForm(site: Site): SiteForm {
extra: site.extra || "",
user_agent: site.user_agent || "",
rss_url: site.rss_url || "",
timeout: site.timeout ?? 15,
timeout: site.timeout ?? DEFAULT_SITE_TIMEOUT_SECONDS,
priority: site.priority ?? 50,
use_proxy: site.use_proxy || false,
rate_limit: site.rate_limit || false,
@@ -99,7 +103,11 @@ export function siteFormToPayload(
extra: form.extra || "",
user_agent: form.user_agent || "",
rss_url: form.rss_url || "",
timeout: Number(form.timeout) || 15,
timeout:
Number(form.timeout) ||
(API_SITE_TYPES.has(form.type)
? API_SITE_TIMEOUT_SECONDS
: DEFAULT_SITE_TIMEOUT_SECONDS),
priority: Number(form.priority) || 50,
use_proxy: !!form.use_proxy,
rate_limit: !!form.rate_limit,