fix(audiobooks): seed currentTime from ProgressStore so resume works
handlePlayStart now looks up the persisted progress row and emits the saved currentTime in the playback session manifest. Without this every play start began at 0, breaking cross-device resume which is one of the core ABS-app value props. 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
4b97908d92
commit
4bbab4ccf0
@@ -1,6 +1,7 @@
|
||||
package abs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -111,13 +112,15 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) {
|
||||
dateStr := now.UTC().Format("2006-01-02")
|
||||
dayOfWeek := now.UTC().Weekday().String()
|
||||
|
||||
// currentTime seeds the audio element's initial position. Without it the
|
||||
// player stays in BUFFERING. Progress lookup is best-effort: an error here
|
||||
// means the client starts at position 0, which is always correct for a
|
||||
// first listen.
|
||||
//
|
||||
// TODO Stage 5: wire MediaStore.GetAudiobookProgress to supply resume position.
|
||||
// currentTime seeds the audio element's initial position so cross-device
|
||||
// resume works. Lookup is best-effort: any error returns position 0,
|
||||
// which is always correct for a first listen.
|
||||
var currentTime float64
|
||||
currentTime, err = resolveResumeTime(r.Context(), h.deps.ProgressStore, a.UserID, a.ProfileID, contentID)
|
||||
if err != nil {
|
||||
slog.Debug("play: progress lookup failed", "user", a.UserID, "item", contentID, "err", err)
|
||||
// currentTime is already 0 on error path; safe to continue.
|
||||
}
|
||||
|
||||
playbackSession := map[string]any{
|
||||
"id": sessionID,
|
||||
@@ -489,3 +492,21 @@ func stripHTML(s string) string {
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// resolveResumeTime returns the persisted currentTime for (userID, profileID,
|
||||
// contentID) from the progress store, or 0 when no row exists / store is nil.
|
||||
// Returned error is propagated so callers can log it; the caller is expected
|
||||
// to fall back to 0 on error (a fresh-listen start is always correct).
|
||||
func resolveResumeTime(ctx context.Context, store ProgressStore, userID, profileID, contentID string) (float64, error) {
|
||||
if store == nil {
|
||||
return 0, nil
|
||||
}
|
||||
row, err := store.GetProgress(ctx, userID, profileID, contentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if row == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return row.CurrentSeconds, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package abs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProgressStore is a minimal in-memory ProgressStore for the play
|
||||
// resume tests. It returns a fixed row on GetProgress; other methods are
|
||||
// no-ops sufficient to satisfy the interface.
|
||||
type fakeProgressStore struct {
|
||||
row *ProgressRow
|
||||
getErr error
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeProgressStore) GetProgress(_ context.Context, _, _, _ string) (*ProgressRow, error) {
|
||||
f.called = true
|
||||
return f.row, f.getErr
|
||||
}
|
||||
func (f *fakeProgressStore) ListProgressForAudiobooks(_ context.Context, _, _ string, _ int) ([]ProgressRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeProgressStore) UpsertProgress(_ context.Context, _ ProgressRow) error { return nil }
|
||||
func (f *fakeProgressStore) UpdateProgressPosition(_ context.Context, _, _, _ string, _ float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResumeTimeFromProgressStore_HasRow(t *testing.T) {
|
||||
store := &fakeProgressStore{
|
||||
row: &ProgressRow{
|
||||
UserID: "1",
|
||||
ProfileID: "p1",
|
||||
ContentID: "book123",
|
||||
CurrentSeconds: 1234.5,
|
||||
DurationSeconds: 5000,
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
got, err := resolveResumeTime(context.Background(), store, "1", "p1", "book123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != 1234.5 {
|
||||
t.Errorf("resume time = %v, want 1234.5", got)
|
||||
}
|
||||
if !store.called {
|
||||
t.Errorf("ProgressStore.GetProgress not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeTimeFromProgressStore_NoRow(t *testing.T) {
|
||||
store := &fakeProgressStore{row: nil}
|
||||
got, err := resolveResumeTime(context.Background(), store, "1", "", "book123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("resume time = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeTimeFromProgressStore_NilStore(t *testing.T) {
|
||||
got, err := resolveResumeTime(context.Background(), nil, "1", "", "book123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("resume time = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeTimeFromProgressStore_LookupError(t *testing.T) {
|
||||
store := &fakeProgressStore{getErr: errors.New("boom")}
|
||||
got, err := resolveResumeTime(context.Background(), store, "1", "", "book123")
|
||||
if err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("resume time = %v, want 0 on error", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user