From 8c8d4725242631ea829cc04844aab7a100349578 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sun, 7 Jun 2026 21:34:21 +0800 Subject: [PATCH] fix: add smart download classification --- README.md | 1 + README_EN.md | 1 + internal/config/config.go | 1 + internal/handler/downloads.go | 28 +++--- internal/handler/system_extra.go | 1 + internal/service/aria2_adp.go | 2 +- internal/service/download_clients.go | 2 +- internal/service/downloads.go | 44 ++++++++- internal/service/downloads_test.go | 127 ++++++++++++++++++++++++++ internal/service/media_classifier.go | 62 +++++++++++-- internal/service/proxy.go | 17 ++++ internal/service/proxy_test.go | 29 +++++- internal/service/qbittorrent.go | 26 +++++- internal/service/qbittorrent_adp.go | 4 +- internal/service/qbittorrent_login.go | 14 ++- internal/service/qbittorrent_test.go | 25 +++++ internal/service/subscription.go | 5 + internal/service/transmission_adp.go | 12 +-- web/src/api/downloads.ts | 3 + web/src/api/sites.ts | 1 + web/src/pages/SettingsPage.tsx | 9 +- web/src/pages/SiteSearchPage.tsx | 5 +- web/src/pages/ToolsPage.tsx | 9 ++ 23 files changed, 388 insertions(+), 40 deletions(-) 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() { /> 入库时自动整理 +