diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-04 00:17:29 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-04 00:17:29 +0300 |
| commit | 71e004fb4e53ee4ac5c8fb202bad5f5c00dd8dac (patch) | |
| tree | 5b5c817f8418e2777db376999cdffd3ef018239c | |
| parent | c68c54b0212ebc8d111b11c6edff82942c3a4463 (diff) | |
task b: inject app context into AdminService and propagate cancellation to background scans
| -rw-r--r-- | cmd/mediaplayer/main.go | 5 | ||||
| -rw-r--r-- | internal/service/admin.go | 8 | ||||
| -rw-r--r-- | internal/service/admin_test.go | 34 | ||||
| -rw-r--r-- | internal/service/scan.go | 8 | ||||
| -rw-r--r-- | internal/service/scan_test.go | 40 |
5 files changed, 70 insertions, 25 deletions
diff --git a/cmd/mediaplayer/main.go b/cmd/mediaplayer/main.go index 4ff8aa3..93eed12 100644 --- a/cmd/mediaplayer/main.go +++ b/cmd/mediaplayer/main.go @@ -80,12 +80,15 @@ func runWithSignal(args []string, sigCh <-chan os.Signal) error { hasher := auth.NewBCryptHasher(12) sm := auth.NewSessionManager(store, clk, time.Duration(cfg.SessionTimeoutHours)*time.Hour) + appCtx, appCancel := context.WithCancel(context.Background()) + defer appCancel() + prober := probe.NewFFProber() thumbGen := thumb.NewFFmpegGenerator() mediaSvc := service.NewMediaService(store, clk, cfg.MediaRoot, thumbGen, prober) fsScanner := scanner.NewFSScannerWithLogger(store, prober, thumbGen, clk, cfg.MediaRoot, logger) - adminSvc := service.NewAdminServiceWithLogger(store, clk, hasher, fsScanner, cfg.MediaRoot, logger) + adminSvc := service.NewAdminServiceWithLogger(store, clk, hasher, fsScanner, cfg.MediaRoot, appCtx, logger) progressSvc := service.NewProgressService(store, clk) authSvc := service.NewAuthService(store, clk, hasher, sm) diff --git a/internal/service/admin.go b/internal/service/admin.go index 1354005..11406b8 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -21,15 +21,15 @@ type adminService struct { } // NewAdminService creates a concrete AdminService. -func NewAdminService(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string) AdminService { - return NewAdminServiceWithLogger(store, clk, hasher, sc, mediaRoot, slog.Default()) +func NewAdminService(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string, appCtx context.Context) AdminService { + return NewAdminServiceWithLogger(store, clk, hasher, sc, mediaRoot, appCtx, slog.Default()) } // NewAdminServiceWithLogger creates a concrete AdminService with an injected logger. -func NewAdminServiceWithLogger(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string, logger *slog.Logger) AdminService { +func NewAdminServiceWithLogger(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string, appCtx context.Context, logger *slog.Logger) AdminService { return &adminService{ trashService: NewTrashService(store), - scanService: NewScanService(sc, mediaRoot, clk, logger), + scanService: NewScanService(appCtx, sc, mediaRoot, clk, logger), userAdminService: NewUserAdminService(store, clk, hasher), permissionAdminService: NewPermissionAdminService(store, clk), } diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go index 4117c58..17f5373 100644 --- a/internal/service/admin_test.go +++ b/internal/service/admin_test.go @@ -46,7 +46,7 @@ func TestAdminService_ListTrash(t *testing.T) { }, }, } - svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) items, err := svc.ListTrash(ctx) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -67,7 +67,7 @@ func TestAdminService_TriggerRescan(t *testing.T) { return nil }, } - svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media") + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) if err := svc.TriggerRescan(ctx); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -87,7 +87,7 @@ func TestAdminService_TriggerRescan_Error(t *testing.T) { return errors.New("scan failed") }, } - svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media") + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) err := svc.TriggerRescan(ctx) // TriggerRescan now always returns nil immediately; failure is logged in background. if err != nil { @@ -98,7 +98,7 @@ func TestAdminService_TriggerRescan_Error(t *testing.T) { func TestAdminService_TriggerRescan_NilScanner(t *testing.T) { ctx := context.Background() - svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) err := svc.TriggerRescan(ctx) if err == nil { t.Fatal("expected error when scanner is nil") @@ -114,7 +114,7 @@ func TestAdminService_ListUsers(t *testing.T) { }, }, } - svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) users, err := svc.ListUsers(ctx) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -158,7 +158,7 @@ func TestAdminService_CreateUser(t *testing.T) { }, } hasher := &fakeHasher{fixed: "hashed", err: tt.hashErr} - svc := NewAdminService(store, newMockClock(), hasher, nil, "") + svc := NewAdminService(store, newMockClock(), hasher, nil, "", ctx) user, err := svc.CreateUser(ctx, "alice", "secret", false) if tt.wantErr { if err == nil { @@ -187,7 +187,7 @@ func TestAdminService_DeleteUser(t *testing.T) { }, }, } - svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) if err := svc.DeleteUser(ctx, 1); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -210,7 +210,7 @@ func TestAdminService_ListPermissions(t *testing.T) { }, }, } - svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) perms, err := svc.ListPermissions(ctx) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -234,7 +234,7 @@ func TestAdminService_GrantPermission(t *testing.T) { }, }, } - svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) if err := svc.GrantPermission(ctx, 1, 2, model.RoleViewer); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -257,7 +257,7 @@ func TestAdminService_RevokePermission(t *testing.T) { }, }, } - svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) if err := svc.RevokePermission(ctx, 1, 2); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -277,7 +277,7 @@ func TestAdminService_TriggerRescan_CancelsPrevious(t *testing.T) { return scanCtx.Err() }, } - svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media") + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) // Start first scan. if err := svc.TriggerRescan(ctx); err != nil { @@ -308,7 +308,7 @@ func TestAdminService_TriggerRescan_FreshProgressPerScan(t *testing.T) { return nil }, } - svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media") + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) if err := svc.TriggerRescan(ctx); err != nil { t.Fatalf("unexpected error: %v", err) @@ -347,7 +347,7 @@ func TestAdminService_TriggerRescan_FreshProgressPerScan(t *testing.T) { } // We replace the scanner field via reflection? No, easier: just create new service. // Actually, the test verifies per-service fresh progress, so new service is fine. - svc2 := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc2, "/media") + svc2 := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc2, "/media", ctx) if err := svc2.TriggerRescan(ctx); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -380,7 +380,7 @@ func TestAdminService_TriggerRescan_FreshProgressPerScan(t *testing.T) { func TestAdminService_ScanProgress_ReturnsEmptyWhenNotStarted(t *testing.T) { ctx := context.Background() - svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) p := svc.ScanProgress(ctx) if p.Running { t.Fatal("expected not running when no scan started") @@ -402,7 +402,7 @@ func TestAdminService_TriggerRescan_ConcurrentCalls(t *testing.T) { return scanCtx.Err() }, } - svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media") + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) for i := 0; i < 5; i++ { wg.Add(1) @@ -446,7 +446,7 @@ func TestAdminService_ListPermissions_Error(t *testing.T) { }, }, } - svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) _, err := svc.ListPermissions(ctx) if err == nil { t.Fatal("expected error") @@ -466,7 +466,7 @@ func TestAdminService_ListPermissions_Error(t *testing.T) { }, }, } - svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) _, err := svc.ListPermissions(ctx) if err == nil { t.Fatal("expected error") diff --git a/internal/service/scan.go b/internal/service/scan.go index af17f95..ca4aca8 100644 --- a/internal/service/scan.go +++ b/internal/service/scan.go @@ -21,10 +21,11 @@ type scanService struct { mu sync.Mutex scanCancel context.CancelFunc progress *model.ScanProgress + appCtx context.Context // application-level context used to propagate shutdown cancellation } // NewScanService creates a ScanService. -func NewScanService(sc scanner.Scanner, mediaRoot string, clk clock.Clock, logger *slog.Logger) *scanService { +func NewScanService(appCtx context.Context, sc scanner.Scanner, mediaRoot string, clk clock.Clock, logger *slog.Logger) *scanService { if logger == nil { logger = slog.Default() } @@ -33,6 +34,7 @@ func NewScanService(sc scanner.Scanner, mediaRoot string, clk clock.Clock, logge mediaRoot: mediaRoot, clock: clk, logger: logger, + appCtx: appCtx, } } @@ -45,7 +47,9 @@ func (s *scanService) TriggerRescan(ctx context.Context) error { if s.scanCancel != nil { s.scanCancel() } - scanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + // 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 diff --git a/internal/service/scan_test.go b/internal/service/scan_test.go index 687776d..aba647f 100644 --- a/internal/service/scan_test.go +++ b/internal/service/scan_test.go @@ -37,7 +37,7 @@ func TestScanService_ScanLibrary(t *testing.T) { return tt.scanErr }, } - svc := NewScanService(sc, "/media", clock.RealClock{}, nil) + svc := NewScanService(ctx, sc, "/media", clock.RealClock{}, nil) err := svc.TriggerRescan(ctx) if tt.wantErr { if err == nil { @@ -57,3 +57,41 @@ func TestScanService_ScanLibrary(t *testing.T) { }) } } + +func TestScanService_CancelledByAppContext(t *testing.T) { + appCtx, appCancel := context.WithCancel(context.Background()) + defer appCancel() + + done := make(chan struct{}) + started := make(chan struct{}) + sc := &fakeScanner{ + scanFunc: func(scanCtx context.Context, _ string, progress *model.ScanProgress) error { + close(started) + <-scanCtx.Done() + close(done) + return scanCtx.Err() + }, + } + + svc := NewScanService(appCtx, sc, "/media", clock.RealClock{}, nil) + if err := svc.TriggerRescan(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + <-started + appCancel() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for scan goroutine to exit after app context cancellation") + } + + p := svc.ScanProgress(context.Background()) + if p.Running { + t.Fatal("expected scan to be stopped after app context cancellation") + } + if p.LastError == "" { + t.Fatal("expected a last error after cancellation") + } +} |
