feat: 实现下载完成后自动触发智能分类整理

- 添加 AutoAfterDownload 配置字段到 OrganizerConfig
- 设置默认值为 false
- 修改 DownloadService 结构,添加 organizer 和 prevStates 字段
- 修改 NewDownloadService 构造函数,接受 organizer 参数
- 修改 service.go 初始化顺序,将 organizer 创建移到 downloads 之前
- 修改 poll() 方法,检测下载完成并触发整理
- 实现 onTorrentComplete() 方法,根据 savePath 查找 Media 并整理
This commit is contained in:
ShukeBta
2026-05-18 03:10:29 +08:00
parent ff14251371
commit 68083a2840
3 changed files with 67 additions and 17 deletions
+4 -2
View File
@@ -133,8 +133,9 @@ type AIConfig struct {
// OrganizerConfig 配置媒体文件智能分类整理。
type OrganizerConfig struct {
SmartClassify bool `mapstructure:"smart_classify"`
Categories map[string]string `mapstructure:"categories"`
SmartClassify bool `mapstructure:"smart_classify"`
AutoAfterDownload bool `mapstructure:"auto_after_download"`
Categories map[string]string `mapstructure:"categories"`
}
// FlareSolverrConfig 配置 FlareSolverr 服务(用于绕过 Cloudflare/WAF)。
@@ -234,6 +235,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("flaresolverr.timeout", 60)
v.SetDefault("organizer.smart_classify", false)
v.SetDefault("organizer.auto_after_download", false)
v.SetDefault("organizer.categories.chinese_movie", "华语电影")
v.SetDefault("organizer.categories.foreign_movie", "外语电影")
v.SetDefault("organizer.categories.euus_movie", "欧美电影")
+61 -13
View File
@@ -29,24 +29,28 @@ import (
// DownloadService is the single download orchestrator.
type DownloadService struct {
log *zap.Logger
repo *repository.Container
hub *Hub
qb *QBitClient
log *zap.Logger
repo *repository.Container
hub *Hub
qb *QBitClient
organizer *OrganizerService
mu sync.Mutex
stopCh chan struct{}
pollOnce sync.Once
mu sync.Mutex
stopCh chan struct{}
pollOnce sync.Once
prevStates map[string]bool // hash -> wasCompleted
}
// NewDownloadService is the constructor.
func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub) *DownloadService {
func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub, organizer *OrganizerService) *DownloadService {
return &DownloadService{
log: log,
repo: repo,
hub: hub,
qb: NewQBitClient(log, QBitConfig{}),
stopCh: make(chan struct{}),
log: log,
repo: repo,
hub: hub,
qb: NewQBitClient(log, QBitConfig{}),
organizer: organizer,
prevStates: make(map[string]bool),
stopCh: make(chan struct{}),
}
}
@@ -131,6 +135,10 @@ func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles boo
func (d *DownloadService) poll(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
// prevStates tracks previous completion states to detect "just finished"
if d.prevStates == nil {
d.prevStates = make(map[string]bool)
}
for {
select {
case <-ctx.Done():
@@ -143,6 +151,46 @@ func (d *DownloadService) poll(ctx context.Context) {
if err != nil {
continue
}
// Detect completed downloads and trigger organize
for _, t := range live {
hash := t.Hash
complete := t.Progress >= 1.0
if complete && !d.prevStates[hash] {
// Just completed: trigger organize
go d.onTorrentComplete(ctx, hash, t.SavePath)
}
d.prevStates[hash] = complete
}
d.hub.Publish("download", map[string]any{"torrents": live})
}
}
// onTorrentComplete handles a torrent that just finished downloading.
// It tries to find the associated Media record and trigger organize.
func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, savePath string) {
if d.organizer == nil || savePath == "" {
return
}
// Check if auto-organize after download is enabled
autoOrganize := d.organizer.isSmartClassifyEnabled(ctx)
// Also check dedicated config key
if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil {
autoOrganize = autoOrganize || v == "true" || v == "1" || v == "on"
}
if !autoOrganize {
d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash))
return
}
d.log.Info("download completed, triggering organize", zap.String("hash", hash), zap.String("save_path", savePath))
// Find Media record by path prefix
var medias []model.Media
if err := d.repo.DB.WithContext(ctx).Where("path LIKE ?", savePath+"%").Find(&medias).Error; err != nil {
d.log.Error("find media by path", zap.Error(err))
return
}
for i := range medias {
if _, err := d.organizer.OrganizeMedia(ctx, medias[i].ID); err != nil {
d.log.Error("organize media", zap.String("media_id", medias[i].ID), zap.Error(err))
}
}
}
+2 -2
View File
@@ -88,10 +88,11 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
thetvdb := NewTheTVDBProvider(cfg, log)
fanart := NewFanartProvider(cfg, log)
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub)
organizer := NewOrganizerService(cfg, log, repos)
downloads := NewDownloadService(log, repos, hub, organizer)
discover := NewDiscoverService(log, tmdb)
transcoder := NewTranscoderService(cfg, log, repos, hub)
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
downloads := NewDownloadService(log, repos, hub)
subscription := NewSubscriptionService(log, repos, downloads, hub)
watcher := NewWatcherService(log, repos, scanner)
nfo := NewNFOService(log, repos)
@@ -109,7 +110,6 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
storageCfg := NewStorageConfigService(log, repos, crypto)
downloadClients := NewDownloadClientService(log, repos)
assistant := NewAssistantService(log, repos, ai)
organizer := NewOrganizerService(cfg, log, repos)
douban := NewDoubanProvider(cfg, log)
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)