summaryrefslogtreecommitdiff
path: root/player-server/internal/service/gc.go
diff options
context:
space:
mode:
Diffstat (limited to 'player-server/internal/service/gc.go')
-rw-r--r--player-server/internal/service/gc.go169
1 files changed, 169 insertions, 0 deletions
diff --git a/player-server/internal/service/gc.go b/player-server/internal/service/gc.go
new file mode 100644
index 0000000..6eb485a
--- /dev/null
+++ b/player-server/internal/service/gc.go
@@ -0,0 +1,169 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "codeberg.org/snonux/player/internal/clock"
+ "codeberg.org/snonux/player/internal/repository"
+)
+
+// GCWorker is a background worker that hard-deletes soft-deleted media older than a threshold.
+type GCWorker struct {
+ store repository.GCStore
+ clock clock.Clock
+ interval time.Duration
+ age time.Duration
+ logger *slog.Logger
+ ticker *time.Ticker
+ tickCh <-chan time.Time
+ runDoneCh chan struct{}
+ stopCh chan struct{}
+ stopOnce sync.Once
+ wg sync.WaitGroup
+ mediaRoot string
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewGCWorker creates a GCWorker. Use WithAge and WithInterval to customise.
+func NewGCWorker(store repository.GCStore, clk clock.Clock, mediaRoot string, interval time.Duration, logger *slog.Logger) *GCWorker {
+ return &GCWorker{
+ store: store,
+ clock: clk,
+ interval: interval,
+ age: 7 * 24 * time.Hour,
+ logger: logger,
+ stopCh: make(chan struct{}),
+ mediaRoot: mediaRoot,
+ }
+}
+
+// WithAge overrides the default 7-day deletion age.
+func (w *GCWorker) WithAge(age time.Duration) *GCWorker {
+ w.age = age
+ return w
+}
+
+// WithInterval overrides the ticker interval (used in tests that need deterministic ticks).
+func (w *GCWorker) WithInterval(interval time.Duration) *GCWorker {
+ w.interval = interval
+ return w
+}
+
+// Start launches the GC goroutine.
+func (w *GCWorker) Start() {
+ w.ctx, w.cancel = context.WithCancel(context.Background())
+ tickCh := w.tickCh
+ if tickCh == nil {
+ w.ticker = time.NewTicker(w.interval)
+ tickCh = w.ticker.C
+ }
+ w.wg.Add(1)
+ go func() {
+ defer w.wg.Done()
+ for {
+ select {
+ case <-tickCh:
+ func() {
+ defer func() {
+ handleWorkerPanic(w.logger, "gc", recover())
+ }()
+ w.run(w.ctx)
+ }()
+ w.notifyRunDone()
+ case <-w.stopCh:
+ return
+ }
+ }
+ }()
+}
+
+// Stop stops the GC goroutine and waits for it to finish.
+// Safe to call multiple times or before Start() (idempotent, no-op).
+func (w *GCWorker) Stop() {
+ w.stopOnce.Do(func() {
+ if w.ticker != nil {
+ w.ticker.Stop()
+ }
+ if w.cancel != nil {
+ w.cancel()
+ }
+ close(w.stopCh)
+ })
+ w.wg.Wait()
+}
+
+func (w *GCWorker) run(ctx context.Context) {
+ items, err := w.store.ListDeletedMedia(ctx)
+ if err != nil {
+ if w.logger != nil {
+ w.logger.Error("gc list deleted media", "err", err)
+ }
+ return
+ }
+
+ cutoff := w.clock.Now().Add(-w.age)
+ for _, item := range items {
+ if item.DeletedAt == nil || !item.DeletedAt.Before(cutoff) {
+ continue
+ }
+
+ absPath := item.AbsPath
+ if absPath == "" {
+ absPath = filepath.Clean(filepath.Join(w.mediaRoot, item.RelPath))
+ }
+
+ if absPath != "" {
+ if err := os.Remove(absPath); err != nil {
+ if os.IsNotExist(err) {
+ // File already gone; safe to proceed with DB deletion.
+ } else {
+ if w.logger != nil {
+ w.logger.Warn("gc remove file", "path", absPath, "err", err)
+ }
+ continue
+ }
+ }
+ }
+
+ if err := w.store.HardDeleteMedia(ctx, item.ID); err != nil {
+ if w.logger != nil {
+ w.logger.Error("gc hard delete", "id", item.ID, "err", err)
+ }
+ continue
+ }
+
+ if w.logger != nil {
+ w.logger.Info("gc deleted media", "id", item.ID, "path", absPath)
+ }
+ }
+}
+
+func (w *GCWorker) notifyRunDone() {
+ if w.runDoneCh == nil {
+ return
+ }
+ select {
+ case w.runDoneCh <- struct{}{}:
+ default:
+ }
+}
+
+// RunOnce performs a single GC run synchronously. Useful for tests.
+func (w *GCWorker) RunOnce() error {
+ if w.interval == 0 {
+ return errors.New("worker not started")
+ }
+ ctx := w.ctx
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ w.run(ctx)
+ return nil
+}