diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ef5f57c..c0e3dfc 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -23,10 +23,15 @@ import ( ) // VideoStore is the durable persistence the run loop needs: assign a stable id + -// metadata, and read the already-summarized set. *store.Store satisfies it. +// metadata, read the already-summarized set, and (for manual summarization mode) +// read the user's mode + queued videos and clear a video's queue flag once it has +// been summarized. *store.Store satisfies it. type VideoStore interface { UpsertVideo(ctx context.Context, v domain.Video) (string, error) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) + GetAutoSummarize(ctx context.Context, userID string) (bool, error) + RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error) + ClearSummarizeRequested(ctx context.Context, userID, videoID string) error } // Processor runs the core use case for a single video. *usecase.Engine @@ -59,6 +64,7 @@ type Stats struct { Summarized int SkippedSeen int SkippedNoText int + SkippedManual int // discovered but not queued, in manual mode Errors int } @@ -82,6 +88,22 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { return stats, fmt.Errorf("runner: load seen videos: %w", err) } + // Summarization mode (per-user, ADR-012). Auto = summarize every unseen video + // (the original behavior). Manual = still discover/persist videos so the user + // sees them, but only summarize the ones explicitly queued via the web UI + // (summarize_requested). The queued set is loaded once per pass, like seen. + auto, err := r.store.GetAutoSummarize(ctx, r.userID) + if err != nil { + return stats, fmt.Errorf("runner: load summarize mode: %w", err) + } + var requested map[string]bool + if !auto { + requested, err = r.store.RequestedVideoIDs(ctx, r.userID) + if err != nil { + return stats, fmt.Errorf("runner: load requested videos: %w", err) + } + } + subs, err := r.src.ListSubscriptions(ctx, r.userID) if err != nil { return stats, fmt.Errorf("runner: list subscriptions: %w", err) @@ -112,6 +134,14 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { } seen[id] = true // also guard against the same video within this pass + // Manual mode: skip summarization for videos the user has not queued. + // Discovery already happened (UpsertVideo above), so the new video is + // visible in the list; it just isn't summarized until requested. + if !auto && !requested[id] { + stats.SkippedManual++ + continue + } + if fetchDelay > 0 { time.Sleep(fetchDelay) } @@ -127,6 +157,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title) case res.Summary != nil: stats.Summarized++ + // In manual mode the video was processed because it was queued; + // clear the flag so it is not re-summarized and the UI drops the + // "Queued" chip. (Auto mode never sets the flag.) + if !auto { + if err := r.store.ClearSummarizeRequested(ctx, r.userID, id); err != nil { + errs = append(errs, fmt.Errorf("clear summarize flag %q: %w", v.ProviderVideoID, err)) + stats.Errors++ + } + } r.log.Info("summarized video", "video", v.ProviderVideoID, "title", v.Title, "provider", res.Summary.AIProvider, "model", res.Summary.AIModel) } @@ -145,7 +184,7 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error { r.log.Info("run pass complete", "candidates", stats.Candidates, "summarized", stats.Summarized, "skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText, - "errors", stats.Errors) + "skipped_manual", stats.SkippedManual, "errors", stats.Errors) if err != nil { r.log.Warn("run pass had errors", "err", err) } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 5bacdce..eb08480 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -40,9 +40,14 @@ func (f *fakeSource) FetchTranscript(_ context.Context, v domain.Video) (domain. // fakeStore assigns deterministic ids ("id-"+provider video id) so a pre-seeded // seen set lines up with UpsertVideo output, modelling cross-restart dedup. +// auto controls the summarization mode; requested is the manual-mode queue keyed +// by store id; cleared records the ids whose queue flag the runner reset. type fakeStore struct { - seen map[string]bool - upserted []domain.Video + seen map[string]bool + upserted []domain.Video + auto bool + requested map[string]bool + cleared []string } func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) { @@ -58,6 +63,23 @@ func (f *fakeStore) SeenVideoIDs(_ context.Context, _ string) (map[string]bool, return cp, nil } +func (f *fakeStore) GetAutoSummarize(_ context.Context, _ string) (bool, error) { + return f.auto, nil +} + +func (f *fakeStore) RequestedVideoIDs(_ context.Context, _ string) (map[string]bool, error) { + cp := make(map[string]bool, len(f.requested)) + for k, v := range f.requested { + cp[k] = v + } + return cp, nil +} + +func (f *fakeStore) ClearSummarizeRequested(_ context.Context, _, videoID string) error { + f.cleared = append(f.cleared, videoID) + return nil +} + type fakeSummarizer struct{} func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) { @@ -91,7 +113,7 @@ func TestRunOnce_SummarizesNewVideos(t *testing.T) { subs: []domain.Subscription{sub("chan1", "Channel One")}, videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}}, } - st := &fakeStore{seen: map[string]bool{}} + st := &fakeStore{seen: map[string]bool{}, auto: true} sink := &recordingSink{} eng := usecase.NewEngine(src, fakeSummarizer{}, sink) r := runner.New(src, st, eng, testUser, quietLogger()) @@ -114,7 +136,7 @@ func TestRunOnce_SkipsAlreadySummarized(t *testing.T) { videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}}, } // v1 was summarized in a prior run (durable seen set). - st := &fakeStore{seen: map[string]bool{"id-v1": true}} + st := &fakeStore{seen: map[string]bool{"id-v1": true}, auto: true} sink := &recordingSink{} eng := usecase.NewEngine(src, fakeSummarizer{}, sink) r := runner.New(src, st, eng, testUser, quietLogger()) @@ -133,7 +155,7 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) { videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}}, transcripts: map[string]domain.Transcript{"v1": {Source: domain.SourceNone}}, } - st := &fakeStore{seen: map[string]bool{}} + st := &fakeStore{seen: map[string]bool{}, auto: true} sink := &recordingSink{} eng := usecase.NewEngine(src, fakeSummarizer{}, sink) r := runner.New(src, st, eng, testUser, quietLogger()) @@ -145,13 +167,53 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) { require.Empty(t, sink.delivered, "no summary delivered when there is no transcript") } +func TestRunOnce_ManualMode_SkipsUnrequested(t *testing.T) { + src := &fakeSource{ + subs: []domain.Subscription{sub("chan1", "Channel One")}, + videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}}, + } + // Manual mode, nothing queued: discover (upsert) but summarize nothing. + st := &fakeStore{seen: map[string]bool{}, auto: false, requested: map[string]bool{}} + sink := &recordingSink{} + eng := usecase.NewEngine(src, fakeSummarizer{}, sink) + r := runner.New(src, st, eng, testUser, quietLogger()) + + stats, err := r.RunOnce(context.Background()) + require.NoError(t, err) + require.Equal(t, 2, stats.Candidates) + require.Equal(t, 2, stats.SkippedManual, "manual mode skips unqueued videos") + require.Equal(t, 0, stats.Summarized) + require.Empty(t, sink.delivered, "no summary in manual mode without a request") + require.Len(t, st.upserted, 2, "discovery still persists every candidate") +} + +func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) { + src := &fakeSource{ + subs: []domain.Subscription{sub("chan1", "Channel One")}, + videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}}, + } + // Manual mode, v1 queued (by store id). Only v1 is summarized; its flag clears. + st := &fakeStore{seen: map[string]bool{}, auto: false, requested: map[string]bool{"id-v1": true}} + sink := &recordingSink{} + eng := usecase.NewEngine(src, fakeSummarizer{}, sink) + r := runner.New(src, st, eng, testUser, quietLogger()) + + stats, err := r.RunOnce(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, stats.Summarized, "only the queued video is summarized") + require.Equal(t, 1, stats.SkippedManual, "the unqueued video is skipped") + require.Len(t, sink.delivered, 1) + require.Equal(t, "id-v1", sink.delivered[0].VideoID) + require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing") +} + func TestRunOnce_UpsertsEveryCandidate(t *testing.T) { src := &fakeSource{ subs: []domain.Subscription{sub("chan1", "Channel One")}, videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}}, } // Even an already-seen video gets upserted so its metadata stays fresh. - st := &fakeStore{seen: map[string]bool{"id-v1": true}} + st := &fakeStore{seen: map[string]bool{"id-v1": true}, auto: true} eng := usecase.NewEngine(src, fakeSummarizer{}, &recordingSink{}) r := runner.New(src, st, eng, testUser, quietLogger())