mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
fix: add smart download classification
This commit is contained in:
@@ -1048,6 +1048,7 @@ MediaStationGo 的智能分类分为两个阶段:
|
||||
|
||||
自动整理与手动整理是两件事:
|
||||
|
||||
- `downloads.smart_classify`:控制订阅下载 / 站点搜索下载是否自动按媒体分类写入下载目录和 qB 分类,默认开启。
|
||||
- `organizer.smart_classify`:只控制是否使用智能分类目录。
|
||||
- `organizer.auto_after_download` / `organize.auto`:控制下载完成后是否自动整理。
|
||||
- 未开启自动整理时,可以在「整理与维护」页面手动整理媒体库或单个媒体。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface SiteSearchResult {
|
||||
title: string
|
||||
torrent_url: string
|
||||
download_url: string
|
||||
category?: string
|
||||
size: number
|
||||
seeders: number
|
||||
leechers: number
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user