mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46: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.smart_classify`:只控制是否使用智能分类目录。
|
||||||
- `organizer.auto_after_download` / `organize.auto`:控制下载完成后是否自动整理。
|
- `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:
|
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.smart_classify`: controls smart category folders only.
|
||||||
- `organizer.auto_after_download` / `organize.auto`: controls whether completed downloads are organized automatically.
|
- `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.
|
- 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.session", "mediastation")
|
||||||
v.SetDefault("flaresolverr.timeout", 60)
|
v.SetDefault("flaresolverr.timeout", 60)
|
||||||
|
|
||||||
|
v.SetDefault("downloads.smart_classify", true)
|
||||||
v.SetDefault("organizer.smart_classify", false)
|
v.SetDefault("organizer.smart_classify", false)
|
||||||
v.SetDefault("organizer.auto_after_download", false)
|
v.SetDefault("organizer.auto_after_download", false)
|
||||||
v.SetDefault("organizer.categories.chinese_movie", "华语电影")
|
v.SetDefault("organizer.categories.chinese_movie", "华语电影")
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type addDownloadReq struct {
|
type addDownloadReq struct {
|
||||||
URL string `json:"url" binding:"required"`
|
URL string `json:"url" binding:"required"`
|
||||||
SavePath string `json:"save_path"`
|
SavePath string `json:"save_path"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
PosterURL string `json:"poster_url"`
|
PosterURL string `json:"poster_url"`
|
||||||
BackdropURL string `json:"backdrop_url"`
|
BackdropURL string `json:"backdrop_url"`
|
||||||
Overview string `json:"overview"`
|
Overview string `json:"overview"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
MediaCategory string `json:"media_category"`
|
||||||
|
SourceCategory string `json:"source_category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePTDownloadURL 把站点搜索结果里的"详情/获取签名"URL 解析成 qb 能直接
|
// resolvePTDownloadURL 把站点搜索结果里的"详情/获取签名"URL 解析成 qb 能直接
|
||||||
@@ -67,11 +70,14 @@ func addDownloadHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
fallbackTitle = realURL
|
fallbackTitle = realURL
|
||||||
}
|
}
|
||||||
meta := enrichDownloadTaskMeta(c.Request.Context(), svc, service.DownloadTaskMeta{
|
meta := enrichDownloadTaskMeta(c.Request.Context(), svc, service.DownloadTaskMeta{
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
PosterURL: req.PosterURL,
|
PosterURL: req.PosterURL,
|
||||||
BackdropURL: req.BackdropURL,
|
BackdropURL: req.BackdropURL,
|
||||||
Overview: req.Overview,
|
Overview: req.Overview,
|
||||||
}, fallbackTitle, "")
|
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)
|
t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrMediaAlreadyInLibrary) {
|
if errors.Is(err, service.ErrMediaAlreadyInLibrary) {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
|||||||
"label": "整理 & 刮削",
|
"label": "整理 & 刮削",
|
||||||
"items": []gin.H{
|
"items": []gin.H{
|
||||||
{"key": "organizer.auto_after_download", "type": "toggle"},
|
{"key": "organizer.auto_after_download", "type": "toggle"},
|
||||||
|
{"key": "downloads.smart_classify", "type": "toggle"},
|
||||||
{"key": "organizer.smart_classify", "type": "toggle"},
|
{"key": "organizer.smart_classify", "type": "toggle"},
|
||||||
{"key": "organize.movie_format", "type": "text"},
|
{"key": "organize.movie_format", "type": "text"},
|
||||||
{"key": "organize.tv_format", "type": "text"},
|
{"key": "organize.tv_format", "type": "text"},
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ type Aria2Adapter struct {
|
|||||||
// NewAria2Adapter 创建新的 Aria2 适配器。
|
// NewAria2Adapter 创建新的 Aria2 适配器。
|
||||||
func NewAria2Adapter() *Aria2Adapter {
|
func NewAria2Adapter() *Aria2Adapter {
|
||||||
return &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{
|
return &DownloadClientService{
|
||||||
log: log,
|
log: log,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
client: &http.Client{Timeout: 10 * time.Second},
|
client: NewInternalHTTPClient(10 * time.Second),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ type DownloadTaskMeta struct {
|
|||||||
PosterURL string
|
PosterURL string
|
||||||
BackdropURL string
|
BackdropURL string
|
||||||
Overview string
|
Overview string
|
||||||
|
MediaType string
|
||||||
|
MediaCategory string
|
||||||
|
SourceCategory string
|
||||||
AllowExistingLibrary bool
|
AllowExistingLibrary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,14 +208,18 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
if urlStr == "" {
|
if urlStr == "" {
|
||||||
return nil, errors.New("empty url")
|
return nil, errors.New("empty url")
|
||||||
}
|
}
|
||||||
if savePath == "" {
|
|
||||||
savePath, _ = d.repo.Setting.Get(ctx, "qbittorrent.savepath")
|
|
||||||
}
|
|
||||||
title := strings.TrimSpace(meta.Title)
|
title := strings.TrimSpace(meta.Title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = publicDownloadTitle(urlStr)
|
title = publicDownloadTitle(urlStr)
|
||||||
meta.Title = title
|
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) {
|
if !meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, title) {
|
||||||
return nil, ErrMediaAlreadyInLibrary
|
return nil, ErrMediaAlreadyInLibrary
|
||||||
}
|
}
|
||||||
@@ -231,9 +238,10 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
return task, ErrDownloadAlreadyExists
|
return task, ErrDownloadAlreadyExists
|
||||||
}
|
}
|
||||||
var siteFetchErr error
|
var siteFetchErr error
|
||||||
|
qbitCategory := strings.TrimSpace(meta.MediaCategory)
|
||||||
if d.site != nil {
|
if d.site != nil {
|
||||||
if data, name, err := d.site.FetchTorrentFile(ctx, urlStr); err == 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
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(meta.Title) == "" {
|
if strings.TrimSpace(meta.Title) == "" {
|
||||||
@@ -244,7 +252,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
siteFetchErr = err
|
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") {
|
if siteFetchErr != nil && !strings.Contains(siteFetchErr.Error(), "no matching PT site") {
|
||||||
return nil, errors.Join(err, siteFetchErr)
|
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)
|
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 {
|
func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool {
|
||||||
if d == nil || d.repo == nil || d.repo.DB == nil {
|
if d == nil || d.repo == nil || d.repo.DB == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"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) {
|
func TestReloadConfigDoesNotFallbackToLegacyAfterClientDisabled(t *testing.T) {
|
||||||
var addCalls int32
|
var addCalls int32
|
||||||
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -14,6 +15,8 @@ var (
|
|||||||
classifierSeasonRE = regexp.MustCompile(`(?i)\bS\d{1,2}\b|第\s*\d+\s*季`)
|
classifierSeasonRE = regexp.MustCompile(`(?i)\bS\d{1,2}\b|第\s*\d+\s*季`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DownloadSmartClassifySettingKey = "downloads.smart_classify"
|
||||||
|
|
||||||
type mediaClassifyInput struct {
|
type mediaClassifyInput struct {
|
||||||
MediaType string
|
MediaType string
|
||||||
Title string
|
Title string
|
||||||
@@ -210,8 +213,8 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
base := strings.TrimSpace(sub.SavePath)
|
base := strings.TrimSpace(sub.SavePath)
|
||||||
if base == "" && s.repo != nil && s.repo.Setting != nil {
|
if base == "" {
|
||||||
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
|
base = downloadDefaultSaveRoot(ctx, s.repo)
|
||||||
}
|
}
|
||||||
if base == "" {
|
if base == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -219,15 +222,60 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s
|
|||||||
if !s.isSmartClassifyEnabled(ctx) || category == "" {
|
if !s.isSmartClassifyEnabled(ctx) || category == "" {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
return filepath.Join(base, sanitizeFilename(category))
|
return categoryRoot(base, sanitizeFilename(category))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
|
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
|
||||||
if s != nil && s.repo != nil && s.repo.Setting != nil {
|
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 != "" {
|
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 {
|
func NewExternalTransport() *http.Transport {
|
||||||
return &http.Transport{
|
return &http.Transport{
|
||||||
Proxy: ProxyFromEnvironmentOrSystem,
|
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) {
|
func proxyURLFromProxyServer(proxyServer, requestScheme string) (*url.URL, error) {
|
||||||
proxyServer = strings.TrimSpace(proxyServer)
|
proxyServer = strings.TrimSpace(proxyServer)
|
||||||
if proxyServer == "" {
|
if proxyServer == "" {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func TestProxyURLFromProxyServer(t *testing.T) {
|
func TestProxyURLFromProxyServer(t *testing.T) {
|
||||||
cases := []struct {
|
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 {
|
func NewQBitClient(log *zap.Logger, cfg QBitConfig) *QBitClient {
|
||||||
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||||
jar, _ := cookiejar.New(nil)
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := NewInternalHTTPClient(20 * time.Second)
|
||||||
|
client.Jar = jar
|
||||||
return &QBitClient{
|
return &QBitClient{
|
||||||
log: log,
|
log: log,
|
||||||
cfg: cfg,
|
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."。我们把这些情况也识别为错误并返回,避免
|
// 但 body 里写 "Fails."。我们把这些情况也识别为错误并返回,避免
|
||||||
// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。
|
// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。
|
||||||
func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error {
|
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()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
if err := q.ensureAuth(ctx); err != nil {
|
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)
|
torrentData, torrentName, fetchErr := q.fetchTorrentFile(ctx, magnetOrURL)
|
||||||
useFileUpload := fetchErr == nil && len(torrentData) > 0
|
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 {
|
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 {
|
if len(data) == 0 {
|
||||||
return errors.New("empty torrent data")
|
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 {
|
if err := q.ensureAuth(ctx); err != nil {
|
||||||
return err
|
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, "")
|
before, beforeErr := q.listLocked(ctx, "")
|
||||||
beforeHashes := make(map[string]struct{}, len(before))
|
beforeHashes := make(map[string]struct{}, len(before))
|
||||||
if beforeErr == nil {
|
if beforeErr == nil {
|
||||||
@@ -173,6 +183,9 @@ func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, t
|
|||||||
if savePath != "" {
|
if savePath != "" {
|
||||||
_ = w.WriteField("savepath", savePath)
|
_ = w.WriteField("savepath", savePath)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(category) != "" {
|
||||||
|
_ = w.WriteField("category", sanitizeQBitCategory(category))
|
||||||
|
}
|
||||||
_ = w.Close()
|
_ = w.Close()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
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",
|
q.log.Info("qbittorrent: torrent added",
|
||||||
zap.String("url", redactTorrentURL(magnetOrURL)),
|
zap.String("url", redactTorrentURL(magnetOrURL)),
|
||||||
zap.String("save_path", savePath),
|
zap.String("save_path", savePath),
|
||||||
|
zap.String("category", sanitizeQBitCategory(category)),
|
||||||
zap.Bool("file_upload", useFileUpload),
|
zap.Bool("file_upload", useFileUpload),
|
||||||
zap.String("body", bodyText))
|
zap.String("body", bodyText))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeQBitCategory(category string) string {
|
||||||
|
return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(category, "\r", " "), "\n", " "))
|
||||||
|
}
|
||||||
|
|
||||||
func redactTorrentURL(raw string) string {
|
func redactTorrentURL(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ type QBitAdapter struct {
|
|||||||
// NewQBitAdapter 创建新的 qBittorrent 适配器。
|
// NewQBitAdapter 创建新的 qBittorrent 适配器。
|
||||||
func NewQBitAdapter() *QBitAdapter {
|
func NewQBitAdapter() *QBitAdapter {
|
||||||
jar, _ := cookiejar.New(nil)
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := NewInternalHTTPClient(20 * time.Second)
|
||||||
|
client.Jar = jar
|
||||||
return &QBitAdapter{
|
return &QBitAdapter{
|
||||||
client: &http.Client{Jar: jar, Timeout: 20 * time.Second},
|
client: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -60,7 +61,7 @@ func qbitLoginOnce(ctx context.Context, client *http.Client, baseURL, username,
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return qbitNetworkError(baseURL, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
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)
|
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"
|
"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) {
|
func TestQBitLoginUsesMinimalRequestFirst(t *testing.T) {
|
||||||
var loginAttempts atomic.Int32
|
var loginAttempts atomic.Int32
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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) {
|
func TestQBitLoginRetriesWithRefererWhenRequired(t *testing.T) {
|
||||||
var loginAttempts atomic.Int32
|
var loginAttempts atomic.Int32
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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,
|
PosterURL: sub.PosterURL,
|
||||||
BackdropURL: sub.BackdropURL,
|
BackdropURL: sub.BackdropURL,
|
||||||
Overview: sub.Overview,
|
Overview: sub.Overview,
|
||||||
|
MediaType: mediaType,
|
||||||
|
MediaCategory: mediaCategory,
|
||||||
AllowExistingLibrary: sub.WashEnabled,
|
AllowExistingLibrary: sub.WashEnabled,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if IsDownloadDedupError(err) {
|
if IsDownloadDedupError(err) {
|
||||||
@@ -333,6 +335,9 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
PosterURL: sub.PosterURL,
|
PosterURL: sub.PosterURL,
|
||||||
BackdropURL: sub.BackdropURL,
|
BackdropURL: sub.BackdropURL,
|
||||||
Overview: sub.Overview,
|
Overview: sub.Overview,
|
||||||
|
MediaType: mediaType,
|
||||||
|
MediaCategory: mediaCategory,
|
||||||
|
SourceCategory: item.Category,
|
||||||
AllowExistingLibrary: sub.WashEnabled,
|
AllowExistingLibrary: sub.WashEnabled,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if IsDownloadDedupError(err) {
|
if IsDownloadDedupError(err) {
|
||||||
|
|||||||
@@ -33,17 +33,17 @@ type transmissionRPCResponse struct {
|
|||||||
|
|
||||||
// TransmissionAdapter 是 Transmission 的 DownloadAdapter 实现。
|
// TransmissionAdapter 是 Transmission 的 DownloadAdapter 实现。
|
||||||
type TransmissionAdapter struct {
|
type TransmissionAdapter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg DownloadClientConfig
|
cfg DownloadClientConfig
|
||||||
client *http.Client
|
client *http.Client
|
||||||
tag int
|
tag int
|
||||||
sessionID string
|
sessionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTransmissionAdapter 创建新的 Transmission 适配器。
|
// NewTransmissionAdapter 创建新的 Transmission 适配器。
|
||||||
func NewTransmissionAdapter() *TransmissionAdapter {
|
func NewTransmissionAdapter() *TransmissionAdapter {
|
||||||
return &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()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
_, err := a.rpcLocked(ctx, "torrent-remove", map[string]interface{}{
|
_, err := a.rpcLocked(ctx, "torrent-remove", map[string]interface{}{
|
||||||
"ids": []string{hash},
|
"ids": []string{hash},
|
||||||
"delete-local-data": deleteFiles,
|
"delete-local-data": deleteFiles,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface AddDownloadInput {
|
|||||||
poster_url?: string
|
poster_url?: string
|
||||||
backdrop_url?: string
|
backdrop_url?: string
|
||||||
overview?: string
|
overview?: string
|
||||||
|
media_type?: string
|
||||||
|
media_category?: string
|
||||||
|
source_category?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const downloadsAPI = {
|
export const downloadsAPI = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface SiteSearchResult {
|
|||||||
title: string
|
title: string
|
||||||
torrent_url: string
|
torrent_url: string
|
||||||
download_url: string
|
download_url: string
|
||||||
|
category?: string
|
||||||
size: number
|
size: number
|
||||||
seeders: number
|
seeders: number
|
||||||
leechers: number
|
leechers: number
|
||||||
|
|||||||
@@ -152,11 +152,18 @@ const GROUPS: SettingGroup[] = [
|
|||||||
label: '入库时自动整理',
|
label: '入库时自动整理',
|
||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'downloads.smart_classify',
|
||||||
|
label: '下载器智能分类',
|
||||||
|
type: 'toggle',
|
||||||
|
hint: '订阅下载和站点搜索下载未指定保存路径时,自动按媒体类型/分类写入 qB 保存目录与 qB 分类(如:/downloads/国产剧、/downloads/综艺)。',
|
||||||
|
defaultValue: 'true',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'organizer.smart_classify',
|
key: 'organizer.smart_classify',
|
||||||
label: '启用智能分类',
|
label: '启用智能分类',
|
||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
hint: '根据元数据(语言/国家/类型)自动分类到子目录(如:华语电影、欧美剧、日番)',
|
hint: '整理/入库时根据元数据(语言/国家/类型)自动分类到媒体库子目录(如:华语电影、欧美剧、日番)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'organize.source_dir',
|
key: 'organize.source_dir',
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export function SiteSearchPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
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)}...`)
|
toast.success(`已加入下载: ${item.title.substring(0, 40)}...`)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ function OrganizePanel() {
|
|||||||
const [savingDefaults, setSavingDefaults] = useState(false)
|
const [savingDefaults, setSavingDefaults] = useState(false)
|
||||||
const ORGANIZE_KEYS = [
|
const ORGANIZE_KEYS = [
|
||||||
'organizer.auto_after_download',
|
'organizer.auto_after_download',
|
||||||
|
'downloads.smart_classify',
|
||||||
'organizer.smart_classify',
|
'organizer.smart_classify',
|
||||||
'organize.source_dir',
|
'organize.source_dir',
|
||||||
'organize.target_dir',
|
'organize.target_dir',
|
||||||
@@ -332,6 +333,14 @@ function OrganizePanel() {
|
|||||||
/>
|
/>
|
||||||
入库时自动整理
|
入库时自动整理
|
||||||
</label>
|
</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">
|
<label className="flex items-center gap-2 text-xs text-ink-600">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
Reference in New Issue
Block a user