diff --git a/internal/audiobooks/abs/continue_listening_handler.go b/internal/audiobooks/abs/continue_listening_handler.go new file mode 100644 index 00000000..fd998f47 --- /dev/null +++ b/internal/audiobooks/abs/continue_listening_handler.go @@ -0,0 +1,44 @@ +package abs + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (h *Handler) handleRemoveFromContinueListening(w http.ResponseWriter, r *http.Request) { + h.setHideFromContinue(w, r, true) +} + +func (h *Handler) handleReaddToContinueListening(w http.ResponseWriter, r *http.Request) { + h.setHideFromContinue(w, r, false) +} + +func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hide bool) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + itemID := chi.URLParam(r, "itemId") + if itemID == "" { + http.Error(w, "itemId required", http.StatusBadRequest) + return + } + item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID) + if err != nil || item == nil { + http.Error(w, "item not found", http.StatusNotFound) + return + } + if h.deps.ProgressStore == nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + if err := h.deps.ProgressStore.SetHideFromContinue(r.Context(), a.UserID, a.ProfileID, itemID, hide); err != nil { + slog.Error("abs continue toggle failed", "err", err, "user", a.UserID, "item", itemID, "hide", hide) + http.Error(w, "continue toggle failed", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/internal/audiobooks/abs/continue_listening_handler_test.go b/internal/audiobooks/abs/continue_listening_handler_test.go new file mode 100644 index 00000000..7e4ed0b7 --- /dev/null +++ b/internal/audiobooks/abs/continue_listening_handler_test.go @@ -0,0 +1,75 @@ +package abs + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type recordingProgressFake struct { + fakeProgressStore + mu sync.Mutex + last string +} + +func (f *recordingProgressFake) SetHideFromContinue(_ context.Context, userID, profileID, contentID string, hide bool) error { + f.mu.Lock() + defer f.mu.Unlock() + if hide { + f.last = "hide:" + contentID + } else { + f.last = "show:" + contentID + } + return nil +} + +func TestContinue_Remove_SetsHide(t *testing.T) { + prog := &recordingProgressFake{} + media := &stubMediaStore{known: map[string]*models.MediaItem{"book-1": nil}} + h := New(Dependencies{MediaStore: media, ProgressStore: prog}) + + rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/book-1/remove-from-continue-listening", + map[string]string{"itemId": "book-1"}, nil, "1", "", h.handleRemoveFromContinueListening) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + if got["ok"] != true { + t.Errorf("ok = %v", got["ok"]) + } + if prog.last != "hide:book-1" { + t.Errorf("last = %q, want hide:book-1", prog.last) + } +} + +func TestContinue_Readd_SetsShow(t *testing.T) { + prog := &recordingProgressFake{} + media := &stubMediaStore{known: map[string]*models.MediaItem{"book-1": nil}} + h := New(Dependencies{MediaStore: media, ProgressStore: prog}) + + rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/book-1/readd-to-continue-listening", + map[string]string{"itemId": "book-1"}, nil, "1", "", h.handleReaddToContinueListening) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if prog.last != "show:book-1" { + t.Errorf("last = %q, want show:book-1", prog.last) + } +} + +func TestContinue_UnknownItem_404(t *testing.T) { + prog := &recordingProgressFake{} + media := &stubMediaStore{known: map[string]*models.MediaItem{}} + h := New(Dependencies{MediaStore: media, ProgressStore: prog}) + + rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/ghost/remove-from-continue-listening", + map[string]string{"itemId": "ghost"}, nil, "1", "", h.handleRemoveFromContinueListening) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } +} diff --git a/internal/audiobooks/abs/play_resume_test.go b/internal/audiobooks/abs/play_resume_test.go index e4733640..ee0a4051 100644 --- a/internal/audiobooks/abs/play_resume_test.go +++ b/internal/audiobooks/abs/play_resume_test.go @@ -27,6 +27,9 @@ func (f *fakeProgressStore) UpsertProgress(_ context.Context, _ ProgressRow) err func (f *fakeProgressStore) UpdateProgressPosition(_ context.Context, _, _, _ string, _ float64) error { return nil } +func (f *fakeProgressStore) SetHideFromContinue(_ context.Context, _, _, _ string, _ bool) error { + return nil +} func TestResumeTimeFromProgressStore_HasRow(t *testing.T) { store := &fakeProgressStore{ diff --git a/internal/audiobooks/abs/progress.go b/internal/audiobooks/abs/progress.go index ba17e252..f250a025 100644 --- a/internal/audiobooks/abs/progress.go +++ b/internal/audiobooks/abs/progress.go @@ -32,6 +32,9 @@ type ProgressStore interface { // (userID, profileID, contentID). Used by session sync to avoid overwriting // is_finished / progress_pct that the user set explicitly. UpdateProgressPosition(ctx context.Context, userID, profileID, contentID string, positionSeconds float64) error + // SetHideFromContinue toggles the hide_from_continue flag on a + // progress row. Idempotent — succeeds even when no row matches. + SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error } // ABSPlaybackSessionStore tracks the active /abs/api/items/{id}/play sessions diff --git a/internal/audiobooks/abs_progress_store.go b/internal/audiobooks/abs_progress_store.go index 3aa08a56..628e1b95 100644 --- a/internal/audiobooks/abs_progress_store.go +++ b/internal/audiobooks/abs_progress_store.go @@ -177,3 +177,21 @@ func (s *ABSProgressStore) UpdateProgressPosition(ctx context.Context, userID, p } return nil } + +// SetHideFromContinue sets the hide_from_continue flag for the given +// progress row. Idempotent on missing-row. +func (s *ABSProgressStore) SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error { + uid, err := strconv.Atoi(userID) + if err != nil { + return fmt.Errorf("abs_progress_store: invalid user id %q: %w", userID, err) + } + if _, err := s.Pool.Exec(ctx, ` + UPDATE user_watch_progress + SET hide_from_continue = $4 + WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`, + uid, profileID, contentID, hide, + ); err != nil { + return fmt.Errorf("abs_progress_store: set hide_from_continue: %w", err) + } + return nil +} diff --git a/internal/audiobooks/media_store.go b/internal/audiobooks/media_store.go index 2491f731..5866ddb1 100644 --- a/internal/audiobooks/media_store.go +++ b/internal/audiobooks/media_store.go @@ -342,7 +342,8 @@ func (s *ABSMediaStore) ListContinueListening(ctx context.Context, userID, profi AND wp.user_id::text = $1 AND ($2 = '' OR wp.profile_id = $2) AND wp.position_seconds > 0 - AND COALESCE(wp.completed, FALSE) = FALSE` + libFilter + ` + AND COALESCE(wp.completed, FALSE) = FALSE + AND COALESCE(wp.hide_from_continue, FALSE) = FALSE` + libFilter + ` ORDER BY wp.updated_at DESC LIMIT $3 `