summaryrefslogtreecommitdiff
path: root/internal/service/scan.go
blob: 2a56f9307942281462cf86ee8e4d2d0c61c25222 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package service

import (
	"context"
	"errors"
	"log/slog"
	"sync"
	"time"

	"codeberg.org/snonux/player/internal/clock"
	"codeberg.org/snonux/player/internal/model"
	"codeberg.org/snonux/player/internal/scanner"
)

// scanService handles triggering and tracking media library scans.
type scanService struct {
	scanner    scanner.Scanner
	mediaRoot  string
	clock      clock.Clock
	logger     *slog.Logger
	mu         sync.Mutex
	scanCancel context.CancelFunc
	progress   *model.ScanProgress
	appCtx     context.Context // application-level context used to propagate shutdown cancellation
	doneCh     chan<- struct{}
}

// NewScanService creates a ScanService.
func NewScanService(appCtx context.Context, sc scanner.Scanner, mediaRoot string, clk clock.Clock, logger *slog.Logger) *scanService {
	if logger == nil {
		logger = slog.Default()
	}
	return &scanService{
		scanner:   sc,
		mediaRoot: mediaRoot,
		clock:     clk,
		logger:    logger,
		appCtx:    appCtx,
	}
}

func (s *scanService) TriggerRescan(ctx context.Context) error {
	if s.scanner == nil {
		return errors.New("scanner not configured")
	}

	s.mu.Lock()
	if s.scanCancel != nil {
		s.scanCancel()
	}
	// Derive the scan context from the application-level context so that
	// cancellation propagates on server exit, while still applying a 30-minute timeout.
	scanCtx, cancel := context.WithTimeout(s.appCtx, 30*time.Minute)
	s.scanCancel = cancel
	progress := &model.ScanProgress{}
	s.progress = progress
	s.mu.Unlock()

	go func() {
		defer cancel()
		err := s.scanner.Scan(scanCtx, s.mediaRoot, progress)
		if err == nil {
			err = scanCtx.Err()
		}
		if err != nil {
			progress.Done(err)
			s.logger.Error("rescan failed", "err", err)
		} else {
			progress.Done(nil)
			s.logger.Info("rescan completed")
		}
		s.notifyDone()
	}()
	return nil
}

func (s *scanService) ScanProgress(ctx context.Context) model.ScanProgress {
	s.mu.Lock()
	progress := s.progress
	s.mu.Unlock()
	if progress == nil {
		return model.ScanProgress{}
	}
	return progress.Copy()
}

func (s *scanService) notifyDone() {
	if s.doneCh == nil {
		return
	}
	select {
	case s.doneCh <- struct{}{}:
	default:
	}
}