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:
co-authored by
Claude Opus 4.7
parent
1b91791bf3
commit
ebe8ad3bd7
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user