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) + } +}