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:
RXWatcher
2026-05-26 07:24:18 +02:00
co-authored by Claude Opus 4.7
parent 4b97908d92
commit 4bbab4ccf0
2 changed files with 111 additions and 6 deletions
+27 -6
View File
@@ -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)
}
}