diff --git a/README.md b/README.md index 140e8b1..f84070a 100644 --- a/README.md +++ b/README.md @@ -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`。 + ## 更新与备份 更新镜像: diff --git a/README_EN.md b/README_EN.md index 59b15aa..75085e6 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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: diff --git a/docker-compose.search.yml b/docker-compose.search.yml index 0ce36e1..0f54308 100644 --- a/docker-compose.search.yml +++ b/docker-compose.search.yml @@ -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" diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index abd9e8d..449bca5 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -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" diff --git a/docker-compose.standard.yml b/docker-compose.standard.yml index 8513e63..116361e 100644 --- a/docker-compose.standard.yml +++ b/docker-compose.standard.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 30be917..4ea52aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go index d1a4965..aa5b017 100644 --- a/internal/handler/discover_extra.go +++ b/internal/handler/discover_extra.go @@ -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 { diff --git a/internal/handler/discover_extra_test.go b/internal/handler/discover_extra_test.go index fe92044..b5ec25f 100644 --- a/internal/handler/discover_extra_test.go +++ b/internal/handler/discover_extra_test.go @@ -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) diff --git a/internal/service/download_config_test.go b/internal/service/download_config_test.go index 3e4fcc0..237b70f 100644 --- a/internal/service/download_config_test.go +++ b/internal/service/download_config_test.go @@ -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) { diff --git a/internal/service/downloads_config_runtime.go b/internal/service/downloads_config_runtime.go index aa34910..9223e69 100644 --- a/internal/service/downloads_config_runtime.go +++ b/internal/service/downloads_config_runtime.go @@ -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 } diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go index a435fcc..298962a 100644 --- a/internal/service/site_adapter.go +++ b/internal/service/site_adapter.go @@ -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 diff --git a/internal/service/site_adapter_mteam.go b/internal/service/site_adapter_mteam.go index 4b3eb60..4bd9ad0 100644 --- a/internal/service/site_adapter_mteam.go +++ b/internal/service/site_adapter_mteam.go @@ -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() +} diff --git a/internal/service/site_adapter_test.go b/internal/service/site_adapter_test.go index ab21326..ae32043 100644 --- a/internal/service/site_adapter_test.go +++ b/internal/service/site_adapter_test.go @@ -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 diff --git a/internal/service/site_connection.go b/internal/service/site_connection.go index 8422710..7ecd469 100644 --- a/internal/service/site_connection.go +++ b/internal/service/site_connection.go @@ -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() diff --git a/internal/service/site_crud.go b/internal/service/site_crud.go index cc592a0..46a2183 100644 --- a/internal/service/site_crud.go +++ b/internal/service/site_crud.go @@ -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 + } +} diff --git a/internal/service/site_search.go b/internal/service/site_search.go index f6b7008..53bd491 100644 --- a/internal/service/site_search.go +++ b/internal/service/site_search.go @@ -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() diff --git a/internal/service/strm_generate.go b/internal/service/strm_generate.go index 0709086..9b64704 100644 --- a/internal/service/strm_generate.go +++ b/internal/service/strm_generate.go @@ -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 diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go index 641e37d..825b51d 100644 --- a/internal/service/strm_svc_test.go +++ b/internal/service/strm_svc_test.go @@ -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) diff --git a/web/src/pages/SitesPage.tsx b/web/src/pages/SitesPage.tsx index d26fd1a..9bff2a2 100644 --- a/web/src/pages/SitesPage.tsx +++ b/web/src/pages/SitesPage.tsx @@ -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, })); }; diff --git a/web/src/pages/sitesPageModel.ts b/web/src/pages/sitesPageModel.ts index 97d313b..b6c6280 100644 --- a/web/src/pages/sitesPageModel.ts +++ b/web/src/pages/sitesPageModel.ts @@ -37,6 +37,10 @@ export const AUTH_TYPE_LABELS: Record = { 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,