From 4bbab4ccf06cbc470aa2e3b825c77627ba07a8b8 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Tue, 26 May 2026 07:24:18 +0200 Subject: [PATCH] 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) --- internal/audiobooks/abs/play_response.go | 33 ++++++-- internal/audiobooks/abs/play_resume_test.go | 84 +++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 internal/audiobooks/abs/play_resume_test.go diff --git a/internal/audiobooks/abs/play_response.go b/internal/audiobooks/abs/play_response.go index 34712f25..32313b21 100644 --- a/internal/audiobooks/abs/play_response.go +++ b/internal/audiobooks/abs/play_response.go @@ -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 +} diff --git a/internal/audiobooks/abs/play_resume_test.go b/internal/audiobooks/abs/play_resume_test.go new file mode 100644 index 00000000..e4733640 --- /dev/null +++ b/internal/audiobooks/abs/play_resume_test.go @@ -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) + } +}