fix: add smart download classification

This commit is contained in:
ShukeBta
2026-06-07 21:34:21 +08:00
parent 681fc33dd5
commit 8c8d472524
23 changed files with 388 additions and 40 deletions
+1
View File
@@ -1048,6 +1048,7 @@ MediaStationGo 的智能分类分为两个阶段:
自动整理与手动整理是两件事:
- `downloads.smart_classify`:控制订阅下载 / 站点搜索下载是否自动按媒体分类写入下载目录和 qB 分类,默认开启。
- `organizer.smart_classify`:只控制是否使用智能分类目录。
- `organizer.auto_after_download` / `organize.auto`:控制下载完成后是否自动整理。
- 未开启自动整理时,可以在「整理与维护」页面手动整理媒体库或单个媒体。
+1
View File
@@ -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.
+1
View File
@@ -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", "华语电影")
+17 -11
View File
@@ -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) {
+1
View File
@@ -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"},
+1 -1
View File
@@ -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),
}
}
+1 -1
View File
@@ -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),
}
}
+39 -5
View File
@@ -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
+127
View File
@@ -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) {
+55 -7
View File
@@ -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()
}
+17
View File
@@ -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 == "" {
+28 -1
View File
@@ -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)
}
}
+22 -4
View File
@@ -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 == "" {
+3 -1
View File
@@ -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,
}
}
+13 -1
View File
@@ -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)
}
+25
View File
@@ -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) {
+5
View File
@@ -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) {
+6 -6
View File
@@ -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
+3
View File
@@ -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 = {
+1
View File
@@ -10,6 +10,7 @@ export interface SiteSearchResult {
title: string
torrent_url: string
download_url: string
category?: string
size: number
seeders: number
leechers: number
+8 -1
View File
@@ -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',
+4 -1
View File
@@ -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 =
+9
View File
@@ -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() {
/>
入库时自动整理
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={!defaults['downloads.smart_classify'] || isOn(defaults['downloads.smart_classify'])}
onChange={(e) => setDefault('downloads.smart_classify', e.target.checked ? 'true' : 'false')}
/>
下载器智能分类
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"