feat(audiobooks): continue-listening toggles

Two GET endpoints (remove-from / readd-to-continue-listening) backed
by ProgressStore.SetHideFromContinue. ListContinueListening SQL gains
a hide_from_continue = false filter so hidden items drop off the shelf
immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 21:57:01 +02:00
co-authored by Claude Opus 4.7
parent 1b91791bf3
commit ebe8ad3bd7
6 changed files with 145 additions and 1 deletions
@@ -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})
}
@@ -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)
}
}
@@ -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{
+3
View File
@@ -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
+18
View File
@@ -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
}
+2 -1
View File
@@ -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
`