Files
MeBox/internal/service/scheduler.go
T
2026-06-13 16:32:38 +08:00

625 lines
18 KiB
Go

// Package service — periodic scheduled jobs.
//
// SchedulerService runs five recurring background jobs that keep the
// library up-to-date without operator intervention:
//
// library_scan every 60 min — re-scan every enabled library so
// newly-copied files are picked up.
// subscription_pull every 30 min — re-poll RSS feeds (in addition to
// the existing SubscriptionService
// internal timer).
// download_sync every 30 s — refresh the qBittorrent torrent
// list (already covered by the
// download poller, kept here as a
// watchdog).
// transcode_cleanup every 24 h — purge HLS transcode artefacts
// older than 24 h.
// recycle_purge every 24 h — empty the recycle bin of rows
// soft-deleted more than 30 days
// ago.
//
// 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"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// SchedulerService runs the periodic jobs.
type SchedulerService struct {
log *zap.Logger
repo *repository.Container
scanner *ScannerService
transcoder *TranscoderService
organizer *OrganizerService
storageCfg *StorageConfigService
hub *Hub
tasks *TaskTrackerService
cacheDir string
now func() time.Time
mu sync.Mutex
stopCh chan struct{}
jobs []*scheduledJob
}
func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
s.tasks = tasks
}
// scheduledJob is one recurring task.
type scheduledJob struct {
name string
interval time.Duration
run func(ctx context.Context) error
lastRun time.Time
lastErr string
}
type schedulerManualRunKey struct{}
const (
cloudAutoSyncEnabledKey = "cloud.auto_sync_enabled"
cloudSyncIntervalSecondsKey = "cloud.sync_interval_seconds"
cloudLastAutoSyncDateKey = "cloud.last_auto_sync_date"
cloudAutoSyncWindowStartHour = 19
cloudAutoSyncWindowEndHour = 21
cloudAutoSyncCompletedDateForm = "2006-01-02"
)
// NewSchedulerService is the constructor.
func NewSchedulerService(
log *zap.Logger,
repo *repository.Container,
scanner *ScannerService,
transcoder *TranscoderService,
organizer *OrganizerService,
storageCfg *StorageConfigService,
hub *Hub,
cacheDir string,
) *SchedulerService {
return &SchedulerService{
log: log,
repo: repo,
scanner: scanner,
transcoder: transcoder,
organizer: organizer,
storageCfg: storageCfg,
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: 60 * time.Minute,
run: s.jobScanLibraries,
},
{
name: "cloud_sync",
interval: s.cloudSyncInterval(ctx),
run: s.jobSyncCloudLibraries,
},
{
name: "cloud_upload",
interval: s.cloudUploadInterval(ctx),
run: s.jobUploadLocalToCloud,
},
{
name: "organize_source",
interval: s.organizeSourceInterval(ctx),
run: s.jobOrganizeSource,
},
{
name: "transcode_cleanup",
interval: 24 * time.Hour,
run: s.jobCleanTranscodeCache,
},
{
name: "recycle_purge",
interval: 24 * time.Hour,
run: s.jobPurgeRecycleBin,
},
}
for _, j := range s.jobs {
initialDelay := 15 * time.Second
if j.name == "library_scan" || j.name == "organize_source" {
// 重启后不立即整库重扫/整理下载目录:更新窗口恰是登录高峰,
// 15 秒即全量 walk + ffprobe 曾把 CPU/磁盘打满导致无法登录。
// 首轮等满一个完整周期再跑,平时节奏不变。
initialDelay = j.interval
}
go 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)
}
}
// JobStatus is a snapshot suitable for the admin UI.
type JobStatus struct {
Name string `json:"name"`
Interval string `json:"interval"`
LastRun time.Time `json:"last_run,omitempty"`
LastErr string `json:"last_err,omitempty"`
}
// Status returns the current state of every registered job.
func (s *SchedulerService) Status() []JobStatus {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]JobStatus, 0, len(s.jobs))
for _, j := range s.jobs {
out = append(out, JobStatus{
Name: j.name,
Interval: j.interval.String(),
LastRun: j.lastRun,
LastErr: j.lastErr,
})
}
return out
}
// RunNow triggers a single run of the named job synchronously.
func (s *SchedulerService) RunNow(ctx context.Context, name string) error {
for _, j := range s.jobs {
if j.name == name {
return s.runOnce(context.WithValue(ctx, schedulerManualRunKey{}, true), j)
}
}
return nil
}
func (s *SchedulerService) loop(ctx context.Context, j *scheduledJob) {
s.loopWithInitialDelay(ctx, j, 15*time.Second)
}
func (s *SchedulerService) loopWithInitialDelay(ctx context.Context, j *scheduledJob, initialDelay time.Duration) {
delay := initialDelay
for {
if delay < 0 {
delay = 0
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return
case <-s.stopCh:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return
case <-timer.C:
}
if err := s.runOnce(ctx, j); err != nil {
s.log.Warn("scheduled job failed",
zap.String("name", j.name), zap.Error(err))
}
delay = j.interval
}
}
func (s *SchedulerService) runOnce(ctx context.Context, j *scheduledJob) error {
err := j.run(ctx)
s.mu.Lock()
j.lastRun = s.currentTime()
if err != nil {
j.lastErr = err.Error()
} else {
j.lastErr = ""
}
s.mu.Unlock()
if s.hub != nil {
s.hub.Publish("scheduler", map[string]any{
"name": j.name,
"ok": err == nil,
"error": j.lastErr,
})
}
return err
}
// jobScanLibraries re-walks every enabled library.
//
// 默认关闭:文件变更由 WatcherService 增量入库,无需周期性全量重扫。
// 仅当用户在设置中显式开启 scan.periodic_enabled 时才执行整库重扫,
// 避免对硬盘的高频反复读取造成损伤(用户明确要求)。
func (s *SchedulerService) jobScanLibraries(ctx context.Context) error {
if !s.periodicScanEnabled(ctx) {
return nil
}
libs, err := s.repo.Library.List(ctx)
if err != nil {
return err
}
for _, l := range libs {
if !l.Enabled {
continue
}
if _, ok := ParseCloudLibraryMount(l.Path); ok {
// 云盘库由 cloud_sync 任务在夜间窗口低频同步;周期性整库
// 重扫只面向本地磁盘库。否则十几个云盘库每小时全量遍历
// 会把 CPU/网络长期吃满,还会占住唯一的云扫描槽位,让
// 手动扫描看起来一直"卡死"在排队。
continue
}
if _, err := s.scanner.ScanLibrary(ctx, l.ID); err != nil {
s.log.Warn("scheduled scan failed",
zap.String("library", l.ID), zap.Error(err))
}
}
return nil
}
// jobUploadLocalToCloud copies local media files into the configured external
// storage backend. It is opt-in and never deletes the local source files.
func (s *SchedulerService) jobUploadLocalToCloud(ctx context.Context) error {
manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
if s.storageCfg == nil || (!manual && !s.autoCloudUploadEnabled(ctx)) {
return nil
}
input := s.cloudUploadInput(ctx)
if strings.TrimSpace(input.Type) == "" || strings.TrimSpace(input.SourcePath) == "" {
return nil
}
res, err := s.storageCfg.UploadLocal(ctx, input)
if s.log != nil && res != nil {
s.log.Info("cloud upload finished",
zap.String("type", input.Type),
zap.String("source", res.SourcePath),
zap.String("dest", res.DestPath),
zap.Int("uploaded", res.Uploaded),
zap.Int("skipped", res.Skipped),
zap.Int64("bytes", res.Bytes),
zap.Int("errors", len(res.Errors)),
)
}
return err
}
func (s *SchedulerService) cloudUploadInput(ctx context.Context) CloudUploadInput {
get := func(key string) string {
if s.repo == nil || s.repo.Setting == nil {
return ""
}
v, _ := s.repo.Setting.Get(ctx, key)
return strings.TrimSpace(v)
}
return CloudUploadInput{
Type: get(CloudUploadProviderKey),
SourcePath: get(CloudUploadSourceDirKey),
DestPath: get(CloudUploadDestPathKey),
Recursive: parseBoolSetting(get(CloudUploadRecursiveKey), true),
IncludeSidecars: parseBoolSetting(get(CloudUploadSidecarsKey), true),
Overwrite: parseBoolSetting(get(CloudUploadOverwriteKey), false),
}
}
func (s *SchedulerService) autoCloudUploadEnabled(ctx context.Context) bool {
if s.repo == nil || s.repo.Setting == nil {
return false
}
v, err := s.repo.Setting.Get(ctx, CloudUploadAutoEnabledKey)
if err != nil {
return false
}
return parseBoolSetting(v, false)
}
func (s *SchedulerService) cloudUploadInterval(ctx context.Context) time.Duration {
const fallback = time.Hour
if s.repo == nil || s.repo.Setting == nil {
return fallback
}
v, err := s.repo.Setting.Get(ctx, CloudUploadIntervalSecondsKey)
if err != nil {
return fallback
}
seconds, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || seconds <= 0 {
return fallback
}
if seconds < 300 {
seconds = 300
}
return time.Duration(seconds) * time.Second
}
// jobSyncCloudLibraries keeps mounted cloud:// libraries refreshed without
// enabling full disk scans. It imports remote cloud files as STRM-backed media
// rows; the actual bytes stay on the provider and playback continues through
// /api/cloud/play 302/proxy.
func (s *SchedulerService) jobSyncCloudLibraries(ctx context.Context) error {
manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
if s.scanner == nil || (!manual && !s.autoCloudSyncDue(ctx, s.currentTime())) {
return nil
}
libs, err := s.repo.Library.List(ctx)
if err != nil {
return err
}
libs = FilterScannableCloudLibraries(ctx, s.repo, libs)
var firstErr error
for _, l := range libs {
if !l.Enabled {
continue
}
if _, ok := ParseCloudLibraryMount(l.Path); !ok {
continue
}
if _, err := s.scanner.ScanLibraryWithoutAutoScrape(ctx, l.ID); err != nil {
s.log.Warn("cloud sync failed", zap.String("library", l.ID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
if firstErr != nil {
return firstErr
}
if !manual {
_ = s.markCloudAutoSyncCompleted(ctx, s.currentTime())
}
return nil
}
func (s *SchedulerService) autoCloudSyncEnabled(ctx context.Context) bool {
if s.repo == nil || s.repo.Setting == nil {
return false
}
v, err := s.repo.Setting.Get(ctx, cloudAutoSyncEnabledKey)
if err != nil {
return false
}
return parseBoolSetting(v, false)
}
func (s *SchedulerService) autoCloudSyncDue(ctx context.Context, now time.Time) bool {
if !s.autoCloudSyncEnabled(ctx) || !cloudAutoSyncInWindow(now) {
return false
}
if s.repo == nil || s.repo.Setting == nil {
return true
}
last, err := s.repo.Setting.Get(ctx, cloudLastAutoSyncDateKey)
if err != nil {
return true
}
return strings.TrimSpace(last) != now.Format(cloudAutoSyncCompletedDateForm)
}
func cloudAutoSyncInWindow(now time.Time) bool {
hour := now.In(time.Local).Hour()
return hour >= cloudAutoSyncWindowStartHour && hour < cloudAutoSyncWindowEndHour
}
func (s *SchedulerService) markCloudAutoSyncCompleted(ctx context.Context, now time.Time) error {
if s.repo == nil || s.repo.Setting == nil {
return nil
}
return s.repo.Setting.Set(ctx, cloudLastAutoSyncDateKey, now.Format(cloudAutoSyncCompletedDateForm))
}
func (s *SchedulerService) cloudSyncInterval(ctx context.Context) time.Duration {
const fallback = 30 * time.Minute
if s.repo == nil || s.repo.Setting == nil {
return fallback
}
v, err := s.repo.Setting.Get(ctx, cloudSyncIntervalSecondsKey)
if err != nil {
return fallback
}
seconds, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || seconds <= 0 {
return fallback
}
if seconds < 300 {
seconds = 300
}
return time.Duration(seconds) * time.Second
}
func (s *SchedulerService) currentTime() time.Time {
if s != nil && s.now != nil {
return s.now()
}
return time.Now()
}
// periodicScanEnabled reports whether the operator opted into periodic full
// library re-scans. Defaults to false so the incremental watcher is the only
// thing touching the disk under normal operation.
func (s *SchedulerService) periodicScanEnabled(ctx context.Context) bool {
if s.repo == nil || s.repo.Setting == nil {
return false
}
v, err := s.repo.Setting.Get(ctx, "scan.periodic_enabled")
if err != nil {
return false
}
return parseBoolSetting(v, false)
}
// jobOrganizeSource periodically organizes the configured staging/download
// source directory into the configured media destination. It is intentionally
// opt-in: manual file management remains available, but background disk walking
// only starts after the operator enables organize.auto.
func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error {
manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
if s.organizer == nil || (!manual && !s.autoOrganizeSourceEnabled(ctx)) {
return nil
}
task := s.startScheduledOrganizeTask(ctx, manual)
res, err := s.organizer.OrganizeDirectory(ctx, OrganizeOptions{})
if err != nil {
if task != nil {
task.Finish(err, TaskUpdate{
Stage: "organize",
Message: "自动整理入库失败",
})
}
return err
}
if task != nil && res != nil {
task.Update(TaskUpdate{
Stage: "organize",
SourcePath: res.SourcePath,
DestPath: res.DestPath,
Message: "自动整理完成,准备扫描入库",
Metrics: OrganizeTaskMetrics(res),
})
}
if s.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" && OrganizeResultHasChanges(res) {
if task != nil {
task.Update(TaskUpdate{
Stage: "scan_scrape",
Message: "正在扫描入库并按设置刮削",
Metrics: OrganizeTaskMetrics(res),
})
}
res.Scans, res.Scrapes = s.scanner.ScanAndScrapeLibrariesForPath(ctx, res.DestPath, "", OrganizeScrapeAfterEnabled(ctx, s.repo))
} else if s.log != nil && res != nil && !OrganizeResultHasChanges(res) {
s.log.Info("scheduled source organize skipped scan; no destination changes",
zap.String("source", res.SourcePath),
zap.String("dest", res.DestPath),
zap.Int("organized", res.Organized),
zap.Int("replaced", res.Replaced),
zap.Int("skipped", res.Skipped),
)
}
if task != nil {
task.Finish(nil, TaskUpdate{
Stage: "completed",
Message: "自动整理入库结束",
Metrics: OrganizeTaskMetrics(res),
})
}
if s.log != nil && res != nil {
s.log.Info("scheduled source organize finished",
zap.String("source", res.SourcePath),
zap.String("dest", res.DestPath),
zap.Int("organized", res.Organized),
zap.Int("replaced", res.Replaced),
zap.Int("skipped", res.Skipped),
zap.Int("scrapes", len(res.Scrapes)),
zap.Int("errors", len(res.Errors)),
)
}
return nil
}
func (s *SchedulerService) startScheduledOrganizeTask(ctx context.Context, manual bool) *TaskHandle {
if s == nil || s.tasks == nil {
return nil
}
name := "自动整理重命名入库"
message := "正在执行计划自动整理/重命名/入库"
if manual {
name = "手动触发自动整理重命名入库"
message = "正在执行手动触发的自动整理/重命名/入库"
}
return s.tasks.Start(TaskKindOrganize, name, TaskUpdate{
Stage: "organize",
SourcePath: s.organizer.defaultSourceRoot(ctx, ""),
DestPath: s.organizer.defaultDestRoot(ctx, ""),
Message: message,
})
}
func (s *SchedulerService) autoOrganizeSourceEnabled(ctx context.Context) bool {
if s.repo == nil || s.repo.Setting == nil {
return false
}
v, err := s.repo.Setting.Get(ctx, "organize.auto")
if err != nil {
return false
}
return parseBoolSetting(v, false)
}
func (s *SchedulerService) organizeSourceInterval(ctx context.Context) time.Duration {
const fallback = 5 * time.Minute
if s.repo == nil || s.repo.Setting == nil {
return fallback
}
v, err := s.repo.Setting.Get(ctx, "organize.interval_seconds")
if err != nil {
return fallback
}
seconds, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || seconds <= 0 {
return fallback
}
if seconds < 60 {
seconds = 60
}
return time.Duration(seconds) * time.Second
}
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
if s.cacheDir == "" {
return nil
}
cutoff := time.Now().Add(-24 * time.Hour)
return walkAndPrune(s.cacheDir+"/hls", cutoff)
}
// jobPurgeRecycleBin permanently deletes media rows soft-deleted >30 days
// ago. The on-disk file is left untouched (delete is operator-driven).
func (s *SchedulerService) jobPurgeRecycleBin(ctx context.Context) error {
cutoff := time.Now().Add(-30 * 24 * time.Hour)
res := s.repo.DB.WithContext(ctx).
Unscoped().
Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
Delete(&model.Media{})
if res.Error != nil && !isMissingTableErr(res.Error) {
return res.Error
}
return nil
}
// isMissingTableErr lets the test harness ignore "no such table" errors
// that show up before AutoMigrate has run.
func isMissingTableErr(err error) bool {
if err == nil {
return false
}
return err == gorm.ErrInvalidDB
}