diff --git a/README.md b/README.md
index 806c6bb..cb76943 100644
--- a/README.md
+++ b/README.md
@@ -1048,6 +1048,7 @@ MediaStationGo 的智能分类分为两个阶段:
自动整理与手动整理是两件事:
+- `downloads.smart_classify`:控制订阅下载 / 站点搜索下载是否自动按媒体分类写入下载目录和 qB 分类,默认开启。
- `organizer.smart_classify`:只控制是否使用智能分类目录。
- `organizer.auto_after_download` / `organize.auto`:控制下载完成后是否自动整理。
- 未开启自动整理时,可以在「整理与维护」页面手动整理媒体库或单个媒体。
diff --git a/README_EN.md b/README_EN.md
index 90e7877..87bb379 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -861,6 +861,7 @@ If the library root is set directly to `/media`, the organizer automatically add
Automatic and manual organization are separate switches:
+- `downloads.smart_classify`: controls whether subscription/site-search downloads are automatically routed into category-specific save paths and qB categories; enabled by default.
- `organizer.smart_classify`: controls smart category folders only.
- `organizer.auto_after_download` / `organize.auto`: controls whether completed downloads are organized automatically.
- If auto organization is disabled, use the Tools page to organize a library or a single media item manually.
diff --git a/internal/config/config.go b/internal/config/config.go
index fa3c29e..faa05d2 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -247,6 +247,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("flaresolverr.session", "mediastation")
v.SetDefault("flaresolverr.timeout", 60)
+ v.SetDefault("downloads.smart_classify", true)
v.SetDefault("organizer.smart_classify", false)
v.SetDefault("organizer.auto_after_download", false)
v.SetDefault("organizer.categories.chinese_movie", "华语电影")
diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go
index 7301a0e..b5e9a81 100644
--- a/internal/handler/downloads.go
+++ b/internal/handler/downloads.go
@@ -17,12 +17,15 @@ import (
)
type addDownloadReq struct {
- URL string `json:"url" binding:"required"`
- SavePath string `json:"save_path"`
- Title string `json:"title"`
- PosterURL string `json:"poster_url"`
- BackdropURL string `json:"backdrop_url"`
- Overview string `json:"overview"`
+ URL string `json:"url" binding:"required"`
+ SavePath string `json:"save_path"`
+ Title string `json:"title"`
+ PosterURL string `json:"poster_url"`
+ BackdropURL string `json:"backdrop_url"`
+ Overview string `json:"overview"`
+ MediaType string `json:"media_type"`
+ MediaCategory string `json:"media_category"`
+ SourceCategory string `json:"source_category"`
}
// resolvePTDownloadURL 把站点搜索结果里的"详情/获取签名"URL 解析成 qb 能直接
@@ -67,11 +70,14 @@ func addDownloadHandler(svc *service.Container) gin.HandlerFunc {
fallbackTitle = realURL
}
meta := enrichDownloadTaskMeta(c.Request.Context(), svc, service.DownloadTaskMeta{
- Title: req.Title,
- PosterURL: req.PosterURL,
- BackdropURL: req.BackdropURL,
- Overview: req.Overview,
- }, fallbackTitle, "")
+ Title: req.Title,
+ PosterURL: req.PosterURL,
+ BackdropURL: req.BackdropURL,
+ Overview: req.Overview,
+ MediaType: req.MediaType,
+ MediaCategory: req.MediaCategory,
+ SourceCategory: req.SourceCategory,
+ }, fallbackTitle, req.MediaType)
t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta)
if err != nil {
if errors.Is(err, service.ErrMediaAlreadyInLibrary) {
diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go
index 4cb9067..fcb723a 100644
--- a/internal/handler/system_extra.go
+++ b/internal/handler/system_extra.go
@@ -79,6 +79,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
"label": "整理 & 刮削",
"items": []gin.H{
{"key": "organizer.auto_after_download", "type": "toggle"},
+ {"key": "downloads.smart_classify", "type": "toggle"},
{"key": "organizer.smart_classify", "type": "toggle"},
{"key": "organize.movie_format", "type": "text"},
{"key": "organize.tv_format", "type": "text"},
diff --git a/internal/service/aria2_adp.go b/internal/service/aria2_adp.go
index 40ca3b1..15708bf 100644
--- a/internal/service/aria2_adp.go
+++ b/internal/service/aria2_adp.go
@@ -49,7 +49,7 @@ type Aria2Adapter struct {
// NewAria2Adapter 创建新的 Aria2 适配器。
func NewAria2Adapter() *Aria2Adapter {
return &Aria2Adapter{
- client: &http.Client{Timeout: 20 * time.Second},
+ client: NewInternalHTTPClient(20 * time.Second),
}
}
diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go
index 1cd7736..2971bb9 100644
--- a/internal/service/download_clients.go
+++ b/internal/service/download_clients.go
@@ -31,7 +31,7 @@ func NewDownloadClientService(log *zap.Logger, repo *repository.Container) *Down
return &DownloadClientService{
log: log,
repo: repo,
- client: &http.Client{Timeout: 10 * time.Second},
+ client: NewInternalHTTPClient(10 * time.Second),
}
}
diff --git a/internal/service/downloads.go b/internal/service/downloads.go
index bc6ddec..1c66561 100644
--- a/internal/service/downloads.go
+++ b/internal/service/downloads.go
@@ -74,6 +74,9 @@ type DownloadTaskMeta struct {
PosterURL string
BackdropURL string
Overview string
+ MediaType string
+ MediaCategory string
+ SourceCategory string
AllowExistingLibrary bool
}
@@ -205,14 +208,18 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
if urlStr == "" {
return nil, errors.New("empty url")
}
- if savePath == "" {
- savePath, _ = d.repo.Setting.Get(ctx, "qbittorrent.savepath")
- }
title := strings.TrimSpace(meta.Title)
if title == "" {
title = publicDownloadTitle(urlStr)
meta.Title = title
}
+ autoClassify := downloadSmartClassifyEnabled(ctx, d.repo, d.organizer)
+ savePath, resolvedCategory := d.resolveDownloadSavePath(ctx, savePath, meta, autoClassify)
+ if !autoClassify {
+ meta.MediaCategory = ""
+ } else if strings.TrimSpace(meta.MediaCategory) == "" {
+ meta.MediaCategory = resolvedCategory
+ }
if !meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, title) {
return nil, ErrMediaAlreadyInLibrary
}
@@ -231,9 +238,10 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
return task, ErrDownloadAlreadyExists
}
var siteFetchErr error
+ qbitCategory := strings.TrimSpace(meta.MediaCategory)
if d.site != nil {
if data, name, err := d.site.FetchTorrentFile(ctx, urlStr); err == nil {
- if err := d.qb.AddTorrentFile(ctx, data, name, savePath); err != nil {
+ if err := d.qb.AddTorrentFileWithCategory(ctx, data, name, savePath, qbitCategory); err != nil {
return nil, err
}
if strings.TrimSpace(meta.Title) == "" {
@@ -244,7 +252,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
siteFetchErr = err
}
}
- if err := d.qb.AddTorrent(ctx, urlStr, savePath); err != nil {
+ if err := d.qb.AddTorrentWithCategory(ctx, urlStr, savePath, qbitCategory); err != nil {
if siteFetchErr != nil && !strings.Contains(siteFetchErr.Error(), "no matching PT site") {
return nil, errors.Join(err, siteFetchErr)
}
@@ -253,6 +261,32 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
return d.createTask(ctx, userID, urlStr, savePath, meta)
}
+func (d *DownloadService) resolveDownloadSavePath(ctx context.Context, explicitSavePath string, meta DownloadTaskMeta, autoClassify bool) (string, string) {
+ if strings.TrimSpace(explicitSavePath) != "" {
+ if !autoClassify {
+ return explicitSavePath, ""
+ }
+ return explicitSavePath, strings.TrimSpace(meta.MediaCategory)
+ }
+ base := downloadDefaultSaveRoot(ctx, d.repo)
+ if strings.TrimSpace(base) == "" {
+ return "", strings.TrimSpace(meta.MediaCategory)
+ }
+ mediaType := normalizeMediaType(meta.MediaType, meta.Title, meta.SourceCategory)
+ category := strings.TrimSpace(meta.MediaCategory)
+ if category == "" {
+ category = classifyMediaCategory(mediaClassifyInput{
+ MediaType: mediaType,
+ Title: meta.Title,
+ Category: meta.SourceCategory,
+ }, downloadCategoryMap(d.organizer))
+ }
+ if !autoClassify || category == "" {
+ return base, ""
+ }
+ return categoryRoot(base, sanitizeFilename(category)), category
+}
+
func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool {
if d == nil || d.repo == nil || d.repo.DB == nil {
return false
diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go
index 96a915e..fbc28d1 100644
--- a/internal/service/downloads_test.go
+++ b/internal/service/downloads_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
+ "path/filepath"
"strings"
"sync/atomic"
"testing"
@@ -378,6 +379,132 @@ func TestReloadConfigDoesNotFallbackToLegacyAfterClientDeleted(t *testing.T) {
}
}
+func TestAddDownloadWithMetaAutoClassifiesSavePathAndQBitCategory(t *testing.T) {
+ var addCalls int32
+ var gotSavePath string
+ var gotCategory string
+ qb := 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(&addCalls) > 0 {
+ _, _ = w.Write([]byte(`[{"hash":"auto123","name":"声生不息 S01E01","state":"downloading","progress":0.1}]`))
+ return
+ }
+ _, _ = w.Write([]byte(`[]`))
+ case "/api/v2/torrents/add":
+ atomic.AddInt32(&addCalls, 1)
+ if err := r.ParseMultipartForm(1024 * 1024); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ gotSavePath = r.FormValue("savepath")
+ gotCategory = r.FormValue("category")
+ _, _ = w.Write([]byte("Ok."))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer qb.Close()
+
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}); err != nil {
+ t.Fatal(err)
+ }
+ repos := repository.New(db)
+ configureTestDefaultQB(t, repos, qb.URL)
+ if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", "/downloads"); 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=%E5%A3%B0%E7%94%9F%E4%B8%8D%E6%81%AF+S01E01", "", DownloadTaskMeta{
+ Title: "声生不息 S01E01",
+ SourceCategory: "综艺",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantPath := filepath.Join("/downloads", "综艺")
+ if task.SavePath != wantPath {
+ t.Fatalf("task save path = %q, want %q", task.SavePath, wantPath)
+ }
+ if gotSavePath != wantPath {
+ t.Fatalf("qb savepath = %q, want %q", gotSavePath, wantPath)
+ }
+ if gotCategory != "综艺" {
+ t.Fatalf("qb category = %q, want 综艺", gotCategory)
+ }
+}
+
+func TestAddDownloadWithMetaCanDisableAutoClassifiedSavePath(t *testing.T) {
+ var addCalls int32
+ var gotSavePath string
+ var gotCategory string
+ qb := 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(&addCalls) > 0 {
+ _, _ = w.Write([]byte(`[{"hash":"auto456","name":"声生不息 S01E01","state":"downloading","progress":0.1}]`))
+ return
+ }
+ _, _ = w.Write([]byte(`[]`))
+ case "/api/v2/torrents/add":
+ atomic.AddInt32(&addCalls, 1)
+ if err := r.ParseMultipartForm(1024 * 1024); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ gotSavePath = r.FormValue("savepath")
+ gotCategory = r.FormValue("category")
+ _, _ = w.Write([]byte("Ok."))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer qb.Close()
+
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}); err != nil {
+ t.Fatal(err)
+ }
+ repos := repository.New(db)
+ configureTestDefaultQB(t, repos, qb.URL)
+ if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", "/downloads"); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.Setting.Set(t.Context(), DownloadSmartClassifySettingKey, "false"); 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:ffffffffffffffffffffffffffffffffffffffff&dn=%E5%A3%B0%E7%94%9F%E4%B8%8D%E6%81%AF+S01E01", "", DownloadTaskMeta{
+ Title: "声生不息 S01E01",
+ SourceCategory: "综艺",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.SavePath != "/downloads" {
+ t.Fatalf("task save path = %q, want /downloads", task.SavePath)
+ }
+ if gotSavePath != "/downloads" {
+ t.Fatalf("qb savepath = %q, want /downloads", gotSavePath)
+ }
+ if gotCategory != "" {
+ t.Fatalf("qb category = %q, want empty", gotCategory)
+ }
+}
+
func TestReloadConfigDoesNotFallbackToLegacyAfterClientDisabled(t *testing.T) {
var addCalls int32
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go
index 4cf5783..3e54269 100644
--- a/internal/service/media_classifier.go
+++ b/internal/service/media_classifier.go
@@ -2,11 +2,12 @@ package service
import (
"context"
- "path/filepath"
+ "os"
"regexp"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
)
var (
@@ -14,6 +15,8 @@ var (
classifierSeasonRE = regexp.MustCompile(`(?i)\bS\d{1,2}\b|第\s*\d+\s*季`)
)
+const DownloadSmartClassifySettingKey = "downloads.smart_classify"
+
type mediaClassifyInput struct {
MediaType string
Title string
@@ -210,8 +213,8 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s
return ""
}
base := strings.TrimSpace(sub.SavePath)
- if base == "" && s.repo != nil && s.repo.Setting != nil {
- base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
+ if base == "" {
+ base = downloadDefaultSaveRoot(ctx, s.repo)
}
if base == "" {
return ""
@@ -219,15 +222,60 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s
if !s.isSmartClassifyEnabled(ctx) || category == "" {
return base
}
- return filepath.Join(base, sanitizeFilename(category))
+ return categoryRoot(base, sanitizeFilename(category))
}
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
if s != nil && s.repo != nil && s.repo.Setting != nil {
- val, err := s.repo.Setting.Get(ctx, "organizer.smart_classify")
+ val, err := s.repo.Setting.Get(ctx, DownloadSmartClassifySettingKey)
if err == nil && val != "" {
- return val == "true" || val == "1" || val == "on"
+ return parseBoolSetting(val, true)
+ }
+ val, err = s.repo.Setting.Get(ctx, "organizer.smart_classify")
+ if err == nil && parseBoolSetting(val, false) {
+ return true
}
}
- return s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify
+ if s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify {
+ return true
+ }
+ return true
+}
+
+func downloadDefaultSaveRoot(ctx context.Context, repo *repository.Container) string {
+ if repo != nil && repo.Setting != nil {
+ if base, _ := repo.Setting.Get(ctx, "qbittorrent.savepath"); strings.TrimSpace(base) != "" {
+ return strings.TrimSpace(base)
+ }
+ }
+ for _, key := range []string{"MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "MEDIASTATION_DOWNLOAD_DIR"} {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func downloadSmartClassifyEnabled(ctx context.Context, repo *repository.Container, organizer *OrganizerService) bool {
+ if repo != nil && repo.Setting != nil {
+ val, err := repo.Setting.Get(ctx, DownloadSmartClassifySettingKey)
+ if err == nil && val != "" {
+ return parseBoolSetting(val, true)
+ }
+ val, err = repo.Setting.Get(ctx, "organizer.smart_classify")
+ if err == nil && parseBoolSetting(val, false) {
+ return true
+ }
+ }
+ if organizer != nil && organizer.cfg != nil && organizer.cfg.Organizer.SmartClassify {
+ return true
+ }
+ return true
+}
+
+func downloadCategoryMap(organizer *OrganizerService) map[string]string {
+ if organizer == nil {
+ return nil
+ }
+ return organizer.categoryMap()
}
diff --git a/internal/service/proxy.go b/internal/service/proxy.go
index 4eeed5a..4410b56 100644
--- a/internal/service/proxy.go
+++ b/internal/service/proxy.go
@@ -26,6 +26,17 @@ func NewExternalHTTPClient(timeout time.Duration) *http.Client {
}
}
+// NewInternalHTTPClient builds an HTTP client for LAN / Docker-internal
+// services such as qBittorrent, Transmission and Aria2. These endpoints are
+// usually 127.0.0.1, host.docker.internal, 172.17.0.1 or a NAS LAN IP; sending
+// them through HTTP_PROXY/SOCKS proxies makes local WebUI logins hang or fail.
+func NewInternalHTTPClient(timeout time.Duration) *http.Client {
+ return &http.Client{
+ Timeout: timeout,
+ Transport: NewInternalTransport(),
+ }
+}
+
func NewExternalTransport() *http.Transport {
return &http.Transport{
Proxy: ProxyFromEnvironmentOrSystem,
@@ -37,6 +48,12 @@ func NewExternalTransport() *http.Transport {
}
}
+func NewInternalTransport() *http.Transport {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ return transport
+}
+
func proxyURLFromProxyServer(proxyServer, requestScheme string) (*url.URL, error) {
proxyServer = strings.TrimSpace(proxyServer)
if proxyServer == "" {
diff --git a/internal/service/proxy_test.go b/internal/service/proxy_test.go
index 720660a..c4ff89f 100644
--- a/internal/service/proxy_test.go
+++ b/internal/service/proxy_test.go
@@ -1,6 +1,10 @@
package service
-import "testing"
+import (
+ "net/http"
+ "testing"
+ "time"
+)
func TestProxyURLFromProxyServer(t *testing.T) {
cases := []struct {
@@ -27,3 +31,26 @@ func TestProxyURLFromProxyServer(t *testing.T) {
})
}
}
+
+func TestNewInternalHTTPClientBypassesProxyEnvironment(t *testing.T) {
+ t.Setenv("HTTP_PROXY", "http://127.0.0.1:9")
+ t.Setenv("HTTPS_PROXY", "http://127.0.0.1:9")
+
+ client := NewInternalHTTPClient(time.Second)
+ req, err := http.NewRequest(http.MethodGet, "http://172.17.0.1:8085", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ transport, ok := client.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("unexpected transport type %T", client.Transport)
+ }
+ if transport.Proxy == nil {
+ return
+ }
+ if proxyURL, err := transport.Proxy(req); err != nil {
+ t.Fatal(err)
+ } else if proxyURL != nil {
+ t.Fatalf("internal downloader client must bypass proxy, got %s", proxyURL)
+ }
+}
diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go
index f553140..ca2f5f7 100644
--- a/internal/service/qbittorrent.go
+++ b/internal/service/qbittorrent.go
@@ -75,10 +75,12 @@ var (
func NewQBitClient(log *zap.Logger, cfg QBitConfig) *QBitClient {
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
jar, _ := cookiejar.New(nil)
+ client := NewInternalHTTPClient(20 * time.Second)
+ client.Jar = jar
return &QBitClient{
log: log,
cfg: cfg,
- client: &http.Client{Jar: jar, Timeout: 20 * time.Second},
+ client: client,
}
}
@@ -112,6 +114,10 @@ func (q *QBitClient) Login(ctx context.Context) error {
// 但 body 里写 "Fails."。我们把这些情况也识别为错误并返回,避免
// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。
func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error {
+ return q.AddTorrentWithCategory(ctx, magnetOrURL, savePath, "")
+}
+
+func (q *QBitClient) AddTorrentWithCategory(ctx context.Context, magnetOrURL, savePath, category string) error {
q.mu.Lock()
defer q.mu.Unlock()
if err := q.ensureAuth(ctx); err != nil {
@@ -120,10 +126,14 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin
torrentData, torrentName, fetchErr := q.fetchTorrentFile(ctx, magnetOrURL)
useFileUpload := fetchErr == nil && len(torrentData) > 0
- return q.addTorrentLocked(ctx, magnetOrURL, torrentData, torrentName, useFileUpload, savePath)
+ return q.addTorrentLocked(ctx, magnetOrURL, torrentData, torrentName, useFileUpload, savePath, category)
}
func (q *QBitClient) AddTorrentFile(ctx context.Context, data []byte, name, savePath string) error {
+ return q.AddTorrentFileWithCategory(ctx, data, name, savePath, "")
+}
+
+func (q *QBitClient) AddTorrentFileWithCategory(ctx context.Context, data []byte, name, savePath, category string) error {
if len(data) == 0 {
return errors.New("empty torrent data")
}
@@ -132,10 +142,10 @@ func (q *QBitClient) AddTorrentFile(ctx context.Context, data []byte, name, save
if err := q.ensureAuth(ctx); err != nil {
return err
}
- return q.addTorrentLocked(ctx, "", data, name, true, savePath)
+ return q.addTorrentLocked(ctx, "", data, name, true, savePath, category)
}
-func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, torrentData []byte, torrentName string, useFileUpload bool, savePath string) error {
+func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, torrentData []byte, torrentName string, useFileUpload bool, savePath, category string) error {
before, beforeErr := q.listLocked(ctx, "")
beforeHashes := make(map[string]struct{}, len(before))
if beforeErr == nil {
@@ -173,6 +183,9 @@ func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, t
if savePath != "" {
_ = w.WriteField("savepath", savePath)
}
+ if strings.TrimSpace(category) != "" {
+ _ = w.WriteField("category", sanitizeQBitCategory(category))
+ }
_ = w.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
@@ -234,11 +247,16 @@ func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, t
q.log.Info("qbittorrent: torrent added",
zap.String("url", redactTorrentURL(magnetOrURL)),
zap.String("save_path", savePath),
+ zap.String("category", sanitizeQBitCategory(category)),
zap.Bool("file_upload", useFileUpload),
zap.String("body", bodyText))
return nil
}
+func sanitizeQBitCategory(category string) string {
+ return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(category, "\r", " "), "\n", " "))
+}
+
func redactTorrentURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
diff --git a/internal/service/qbittorrent_adp.go b/internal/service/qbittorrent_adp.go
index 49d18e3..e108c33 100644
--- a/internal/service/qbittorrent_adp.go
+++ b/internal/service/qbittorrent_adp.go
@@ -31,8 +31,10 @@ type QBitAdapter struct {
// NewQBitAdapter 创建新的 qBittorrent 适配器。
func NewQBitAdapter() *QBitAdapter {
jar, _ := cookiejar.New(nil)
+ client := NewInternalHTTPClient(20 * time.Second)
+ client.Jar = jar
return &QBitAdapter{
- client: &http.Client{Jar: jar, Timeout: 20 * time.Second},
+ client: client,
}
}
diff --git a/internal/service/qbittorrent_login.go b/internal/service/qbittorrent_login.go
index 24f6482..8269dcc 100644
--- a/internal/service/qbittorrent_login.go
+++ b/internal/service/qbittorrent_login.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
+ "net"
"net/http"
"net/url"
"strings"
@@ -60,7 +61,7 @@ func qbitLoginOnce(ctx context.Context, client *http.Client, baseURL, username,
resp, err := client.Do(req)
if err != nil {
- return err
+ return qbitNetworkError(baseURL, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
@@ -78,3 +79,14 @@ func qbitLoginOnce(ctx context.Context, client *http.Client, baseURL, username,
return fmt.Errorf("qbittorrent login unexpected response during %s login: status=%d body=%q", variant.name, resp.StatusCode, text)
}
}
+
+func qbitNetworkError(baseURL string, err error) error {
+ if err == nil {
+ return nil
+ }
+ var netErr net.Error
+ if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) || strings.Contains(err.Error(), "Client.Timeout exceeded") {
+ return fmt.Errorf("qbittorrent: 连接 %s 超时;容器内无法访问该地址。若 qBittorrent 运行在 NAS 宿主机上,请把下载器地址改为 http://host.docker.internal:端口 或 http://172.17.0.1:端口,并确认 docker-compose.yml 包含 extra_hosts: host.docker.internal:host-gateway: %w", baseURL, err)
+ }
+ return fmt.Errorf("qbittorrent: 连接 %s 失败:%w", baseURL, err)
+}
diff --git a/internal/service/qbittorrent_test.go b/internal/service/qbittorrent_test.go
index c2ab2db..f7794cd 100644
--- a/internal/service/qbittorrent_test.go
+++ b/internal/service/qbittorrent_test.go
@@ -15,6 +15,12 @@ import (
"go.uber.org/zap"
)
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
func TestQBitLoginUsesMinimalRequestFirst(t *testing.T) {
var loginAttempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -46,6 +52,25 @@ func TestQBitLoginUsesMinimalRequestFirst(t *testing.T) {
}
}
+func TestQBitLoginTimeoutSuggestsDockerHostAddress(t *testing.T) {
+ client := &http.Client{
+ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return nil, context.DeadlineExceeded
+ }),
+ }
+
+ err := qbitLogin(context.Background(), client, "http://192.168.1.125:8085", "admin", "adminadmin")
+ if err == nil {
+ t.Fatal("expected timeout error")
+ }
+ msg := err.Error()
+ for _, want := range []string{"连接 http://192.168.1.125:8085 超时", "host.docker.internal", "172.17.0.1"} {
+ if !strings.Contains(msg, want) {
+ t.Fatalf("timeout hint %q missing %q", msg, want)
+ }
+ }
+}
+
func TestQBitLoginRetriesWithRefererWhenRequired(t *testing.T) {
var loginAttempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/service/subscription.go b/internal/service/subscription.go
index 6172fb4..38c1db3 100644
--- a/internal/service/subscription.go
+++ b/internal/service/subscription.go
@@ -235,6 +235,8 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
PosterURL: sub.PosterURL,
BackdropURL: sub.BackdropURL,
Overview: sub.Overview,
+ MediaType: mediaType,
+ MediaCategory: mediaCategory,
AllowExistingLibrary: sub.WashEnabled,
}); err != nil {
if IsDownloadDedupError(err) {
@@ -333,6 +335,9 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
PosterURL: sub.PosterURL,
BackdropURL: sub.BackdropURL,
Overview: sub.Overview,
+ MediaType: mediaType,
+ MediaCategory: mediaCategory,
+ SourceCategory: item.Category,
AllowExistingLibrary: sub.WashEnabled,
}); err != nil {
if IsDownloadDedupError(err) {
diff --git a/internal/service/transmission_adp.go b/internal/service/transmission_adp.go
index 22640c3..82a3208 100644
--- a/internal/service/transmission_adp.go
+++ b/internal/service/transmission_adp.go
@@ -33,17 +33,17 @@ type transmissionRPCResponse struct {
// TransmissionAdapter 是 Transmission 的 DownloadAdapter 实现。
type TransmissionAdapter struct {
- mu sync.Mutex
- cfg DownloadClientConfig
- client *http.Client
- tag int
+ mu sync.Mutex
+ cfg DownloadClientConfig
+ client *http.Client
+ tag int
sessionID string
}
// NewTransmissionAdapter 创建新的 Transmission 适配器。
func NewTransmissionAdapter() *TransmissionAdapter {
return &TransmissionAdapter{
- client: &http.Client{Timeout: 20 * time.Second},
+ client: NewInternalHTTPClient(20 * time.Second),
}
}
@@ -208,7 +208,7 @@ func (a *TransmissionAdapter) Remove(ctx context.Context, hash string, deleteFil
a.mu.Lock()
defer a.mu.Unlock()
_, err := a.rpcLocked(ctx, "torrent-remove", map[string]interface{}{
- "ids": []string{hash},
+ "ids": []string{hash},
"delete-local-data": deleteFiles,
})
return err
diff --git a/web/src/api/downloads.ts b/web/src/api/downloads.ts
index 19c38e0..dea11b2 100644
--- a/web/src/api/downloads.ts
+++ b/web/src/api/downloads.ts
@@ -13,6 +13,9 @@ export interface AddDownloadInput {
poster_url?: string
backdrop_url?: string
overview?: string
+ media_type?: string
+ media_category?: string
+ source_category?: string
}
export const downloadsAPI = {
diff --git a/web/src/api/sites.ts b/web/src/api/sites.ts
index 585b151..5a142c9 100644
--- a/web/src/api/sites.ts
+++ b/web/src/api/sites.ts
@@ -10,6 +10,7 @@ export interface SiteSearchResult {
title: string
torrent_url: string
download_url: string
+ category?: string
size: number
seeders: number
leechers: number
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx
index 841daf9..c9ab5f0 100644
--- a/web/src/pages/SettingsPage.tsx
+++ b/web/src/pages/SettingsPage.tsx
@@ -152,11 +152,18 @@ const GROUPS: SettingGroup[] = [
label: '入库时自动整理',
type: 'toggle',
},
+ {
+ key: 'downloads.smart_classify',
+ label: '下载器智能分类',
+ type: 'toggle',
+ hint: '订阅下载和站点搜索下载未指定保存路径时,自动按媒体类型/分类写入 qB 保存目录与 qB 分类(如:/downloads/国产剧、/downloads/综艺)。',
+ defaultValue: 'true',
+ },
{
key: 'organizer.smart_classify',
label: '启用智能分类',
type: 'toggle',
- hint: '根据元数据(语言/国家/类型)自动分类到子目录(如:华语电影、欧美剧、日番)',
+ hint: '整理/入库时根据元数据(语言/国家/类型)自动分类到媒体库子目录(如:华语电影、欧美剧、日番)',
},
{
key: 'organize.source_dir',
diff --git a/web/src/pages/SiteSearchPage.tsx b/web/src/pages/SiteSearchPage.tsx
index c0925bf..836517b 100644
--- a/web/src/pages/SiteSearchPage.tsx
+++ b/web/src/pages/SiteSearchPage.tsx
@@ -53,7 +53,10 @@ export function SiteSearchPage() {
return
}
try {
- await downloadsAPI.add(url, '', { title: item.title })
+ await downloadsAPI.add(url, '', {
+ title: item.title,
+ source_category: item.category,
+ })
toast.success(`已加入下载: ${item.title.substring(0, 40)}...`)
} catch (err: unknown) {
const msg =
diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx
index 09b42ed..31c0396 100644
--- a/web/src/pages/ToolsPage.tsx
+++ b/web/src/pages/ToolsPage.tsx
@@ -71,6 +71,7 @@ function OrganizePanel() {
const [savingDefaults, setSavingDefaults] = useState(false)
const ORGANIZE_KEYS = [
'organizer.auto_after_download',
+ 'downloads.smart_classify',
'organizer.smart_classify',
'organize.source_dir',
'organize.target_dir',
@@ -332,6 +333,14 @@ function OrganizePanel() {
/>
入库时自动整理
+