Files
MeBox/internal/service/scheduler.go
T
truewhile e273b5e87d 优化
2026-09-24 11:43:17 +08:00

190 lines
5.3 KiB
Go

// Package service — periodic scheduled jobs.
//
// SchedulerService runs recurring background jobs that keep the
// library up-to-date without operator intervention:
//
// library_scan every 24 h — optional full re-scan for local libraries;
// filesystem watchers handle normal changes.
// organize_source opt-in — organize the configured staging folder.
// transcode_cleanup every 24 h — purge HLS transcode artefacts
// older than 24 h.
// segment_prewarm every 6 h — fill IntroDB skip segments for queryable media.
//
// Each job runs at most once at a time (an in-flight run blocks the
// next tick). All work happens on a long-lived background context so
// the operator can keep clicking around the UI while the watchdog runs.
package service
import (
"context"
"errors"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
// SchedulerService runs the periodic jobs.
type SchedulerService struct {
log *zap.Logger
repo *repository.Container
scanner *ScannerService
transcoder *TranscoderService
organizer *OrganizerService
organizePipeline *OrganizePipelineService
hub *Hub
tasks *TaskTrackerService
expiryWatcher *TelegramExpiryWatcher
cacheDir string
now func() time.Time
imagesPolicyProvider func() ImageCachePolicy
segments *MediaSegmentService
mu sync.Mutex
stopCh chan struct{}
jobs []*scheduledJob
}
var (
ErrSchedulerJobNotFound = errors.New("scheduled job not found")
ErrSchedulerJobAlreadyRunning = errors.New("scheduled job already running")
)
func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
s.tasks = tasks
}
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
s.organizePipeline = pipeline
}
// SetExpiryWatcher 注入账号到期巡检。未注入时(例如测试)该任务不注册。
func (s *SchedulerService) SetExpiryWatcher(watcher *TelegramExpiryWatcher) {
s.expiryWatcher = watcher
}
// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。
type ImageCachePolicy struct {
TotalBytes int64
OriginalsBytes int64
OriginalsAge time.Duration
}
func (s *SchedulerService) SetImageCachePolicyProvider(fn func() ImageCachePolicy) {
s.imagesPolicyProvider = fn
}
func (s *SchedulerService) imageCachePolicy() ImageCachePolicy {
if s.imagesPolicyProvider != nil {
return s.imagesPolicyProvider()
}
return ImageCachePolicy{}
}
// scheduledJob is one recurring task.
type scheduledJob struct {
name string
interval time.Duration
run func(ctx context.Context) error
lastRun time.Time
lastErr string
running bool
started time.Time
}
type schedulerManualRunKey struct{}
const localLastPeriodicScanDateKey = "scan.last_periodic_date"
// NewSchedulerService is the constructor.
func NewSchedulerService(
log *zap.Logger,
repo *repository.Container,
scanner *ScannerService,
transcoder *TranscoderService,
organizer *OrganizerService,
hub *Hub,
cacheDir string,
) *SchedulerService {
return &SchedulerService{
log: log,
repo: repo,
scanner: scanner,
transcoder: transcoder,
organizer: organizer,
hub: hub,
cacheDir: cacheDir,
now: time.Now,
stopCh: make(chan struct{}),
}
}
// Start kicks off every job in its own goroutine and returns immediately.
func (s *SchedulerService) Start(ctx context.Context) {
s.jobs = []*scheduledJob{
{
name: "library_scan",
interval: 24 * time.Hour,
run: s.jobScanLibraries,
},
{
name: "organize_source",
interval: s.organizeSourceInterval(ctx),
run: s.jobOrganizeSource,
},
{
name: "transcode_cleanup",
interval: 24 * time.Hour,
run: s.jobCleanTranscodeCache,
},
{
name: "image_cache_cleanup",
interval: 1 * time.Hour,
run: s.jobCleanImageCache,
},
}
// 片头预热只在注入了 Segments 时注册,避免测试跑无转外网任务。
if s.segments != nil {
s.jobs = append(s.jobs, &scheduledJob{
name: "segment_prewarm",
interval: segmentPrewarmJobInterval,
run: s.jobSegmentPrewarm,
})
}
// 到期提醒只在配置了巡检器时注册,避免测试与未启用通知的部署跑空转任务。
if s.expiryWatcher != nil {
s.jobs = append(s.jobs, &scheduledJob{
name: "telegram_expiry_warning",
interval: 24 * time.Hour,
run: s.jobTelegramExpiryWarning,
})
}
for _, j := range s.jobs {
initialDelay := 15 * time.Second
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
// 重启后不立即整库重扫/整理/预热:更新窗口恰是登录高峰,
// 15 秒即全量 walk + ffprobe 曾把 CPU/磁盘打满导致无法登录。
// 首轮等满一个完整周期再跑,平时节奏不变。
initialDelay = j.interval
}
helper.Go(s.log, "scheduler.loop."+j.name, func() { s.loopWithInitialDelay(ctx, j, initialDelay) })
}
}
// Stop signals every job loop to exit on the next tick.
func (s *SchedulerService) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
select {
case <-s.stopCh:
// already closed
default:
close(s.stopCh)
}
}