diff --git a/internal/config/config.go b/internal/config/config.go index fe4f7e4..35ce029 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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", "欧美电影") diff --git a/internal/service/downloads.go b/internal/service/downloads.go index c51c9a5..69652d5 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -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)) + } + } +} diff --git a/internal/service/service.go b/internal/service/service.go index f629971..7ff8f63 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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)