mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix docker compose paths and subscription robustness
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user