feat: audio track history
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// maxAudioProgressItems bounds a single upsert payload. A normal save
|
||||
// sends one item; the on-login sync sends the user's whole local set,
|
||||
// which is realistically small. The cap just guards against abuse.
|
||||
const maxAudioProgressItems = 2000
|
||||
|
||||
// AudioProgressHandler serves the audio player's per-file resume points.
|
||||
// All routes are auth-required; the user comes from the request context
|
||||
// populated by middleware.RequireAuth.
|
||||
type AudioProgressHandler struct {
|
||||
svc *service.AudioProgressService
|
||||
}
|
||||
|
||||
func NewAudioProgressHandler(svc *service.AudioProgressService) *AudioProgressHandler {
|
||||
return &AudioProgressHandler{svc: svc}
|
||||
}
|
||||
|
||||
type audioProgressItems struct {
|
||||
Items []model.AudioProgress `json:"items"`
|
||||
}
|
||||
|
||||
// Get — GET /users/me/audio-progress. Returns every saved resume point so
|
||||
// the client can hydrate its in-memory map on sign-in.
|
||||
func (h *AudioProgressHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
items, err := h.svc.List(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load audio progress")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []model.AudioProgress{}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, audioProgressItems{Items: items})
|
||||
}
|
||||
|
||||
// Put — PUT /users/me/audio-progress. Upserts a batch of resume points
|
||||
// (one item for a normal save; N for the on-login sync). Newest-wins is
|
||||
// enforced in SQL, so order/conflicts are safe.
|
||||
func (h *AudioProgressHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
var body audioProgressItems
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body (expect { items: [...] })")
|
||||
return
|
||||
}
|
||||
if len(body.Items) > maxAudioProgressItems {
|
||||
response.Error(w, http.StatusBadRequest, "too many items")
|
||||
return
|
||||
}
|
||||
|
||||
clean := make([]model.AudioProgress, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
if it.Key == "" || it.Position < 0 {
|
||||
continue // drop malformed entries rather than fail the batch
|
||||
}
|
||||
if it.UpdatedAt.IsZero() {
|
||||
it.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
clean = append(clean, it)
|
||||
}
|
||||
|
||||
if err := h.svc.Upsert(r.Context(), uid, clean); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not save audio progress")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Delete — DELETE /users/me/audio-progress?key=<url-encoded key>. Removes a
|
||||
// single resume point (used when a track plays to completion). The key is
|
||||
// a URL, so it travels as a query param to avoid path-encoding issues.
|
||||
func (h *AudioProgressHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
key := r.URL.Query().Get("key")
|
||||
if key == "" {
|
||||
response.Error(w, http.StatusBadRequest, "missing key")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(r.Context(), uid, key); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not delete audio progress")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AudioProgress is one persisted resume point for the floating audio
|
||||
// player — the last-known playback position of a single audio file for a
|
||||
// user. Key is the track URL with query/hash stripped (the stable file
|
||||
// identity, owned by the frontend). Position/Duration mirror the
|
||||
// HTMLAudioElement's currentTime/duration in seconds. UpdatedAt is set by
|
||||
// the client so multi-device writes resolve newest-wins on upsert.
|
||||
type AudioProgress struct {
|
||||
Key string `json:"key"`
|
||||
Position float64 `json:"position"`
|
||||
Duration *float64 `json:"duration,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// AudioProgressRepo wraps Postgres access for audio_progress — the audio
|
||||
// player's per-file resume points. One row per (user, audio_key).
|
||||
type AudioProgressRepo struct{ pg *Postgres }
|
||||
|
||||
func NewAudioProgressRepo(pg *Postgres) *AudioProgressRepo {
|
||||
return &AudioProgressRepo{pg: pg}
|
||||
}
|
||||
|
||||
// ListByUser returns all of the user's saved resume points, newest first.
|
||||
// Used to hydrate the player's in-memory map on sign-in.
|
||||
func (r *AudioProgressRepo) ListByUser(ctx context.Context, userID string) ([]model.AudioProgress, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT audio_key, position_seconds, duration_seconds, updated_at
|
||||
FROM audio_progress
|
||||
WHERE user_id = $1
|
||||
ORDER BY updated_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audio progress: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []model.AudioProgress
|
||||
for rows.Next() {
|
||||
var p model.AudioProgress
|
||||
if err := rows.Scan(&p.Key, &p.Position, &p.Duration, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan audio progress: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert writes a batch of resume points. Each row is inserted or updated
|
||||
// only when the incoming updated_at is at least as new as the stored one
|
||||
// (newest-wins), so concurrent tabs/devices and the on-login sync of
|
||||
// anonymous IndexedDB entries never clobber a fresher position.
|
||||
func (r *AudioProgressRepo) Upsert(ctx context.Context, userID string, items []model.AudioProgress) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for _, it := range items {
|
||||
batch.Queue(`
|
||||
INSERT INTO audio_progress
|
||||
(user_id, audio_key, position_seconds, duration_seconds, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, audio_key) DO UPDATE
|
||||
SET position_seconds = EXCLUDED.position_seconds,
|
||||
duration_seconds = EXCLUDED.duration_seconds,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE EXCLUDED.updated_at >= audio_progress.updated_at`,
|
||||
userID, it.Key, it.Position, it.Duration, it.UpdatedAt)
|
||||
}
|
||||
br := r.pg.Pool.SendBatch(ctx, batch)
|
||||
defer br.Close()
|
||||
for range items {
|
||||
if _, err := br.Exec(); err != nil {
|
||||
return fmt.Errorf("upsert audio progress: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a single resume point (called when a track plays to
|
||||
// completion so it restarts from the beginning next time).
|
||||
func (r *AudioProgressRepo) Delete(ctx context.Context, userID, key string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM audio_progress WHERE user_id = $1 AND audio_key = $2`,
|
||||
userID, key,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete audio progress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -54,6 +54,7 @@ type Server struct {
|
||||
authH *handler.AuthHandler
|
||||
oauthH *handler.OAuthHandler
|
||||
userH *handler.UserHandler
|
||||
audioH *handler.AudioProgressHandler
|
||||
postH *handler.PostHandler
|
||||
commentH *handler.CommentHandler
|
||||
bookmarkH *handler.BookmarkHandler
|
||||
@@ -194,6 +195,11 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||
s.userH = handler.NewUserHandler(userSvc)
|
||||
|
||||
// Audio playback resume points — per-user, per-file last position that
|
||||
// lets the floating player pick up where it left off across devices.
|
||||
audioProgressRepo := repository.NewAudioProgressRepo(s.pg)
|
||||
s.audioH = handler.NewAudioProgressHandler(service.NewAudioProgressService(audioProgressRepo))
|
||||
|
||||
// OAuth — wired only when at least one provider is configured. The
|
||||
// registry skips providers with missing creds, so partial configs (e.g.
|
||||
// Google only, no Facebook) work out of the box.
|
||||
@@ -706,6 +712,13 @@ func (s *Server) setupRouter() {
|
||||
// opaque JSON object.
|
||||
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
||||
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
|
||||
// Audio player resume points roam with the account too, so a
|
||||
// lecture picks up where you left off on any device. Schema is
|
||||
// owned by the frontend; the server stores opaque key/position
|
||||
// rows and merges newest-wins.
|
||||
r.Get("/me/audio-progress", s.audioH.Get)
|
||||
r.Put("/me/audio-progress", s.audioH.Put)
|
||||
r.Delete("/me/audio-progress", s.audioH.Delete)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Package service — AudioProgressService is a thin passthrough over
|
||||
// AudioProgressRepo. The audio player's resume points carry no server-side
|
||||
// business logic (the frontend owns the schema and the newest-wins merge
|
||||
// is enforced in SQL), so this layer exists only to keep the
|
||||
// handler → service → repository shape consistent with the rest of the API.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
type AudioProgressService struct {
|
||||
repo *repository.AudioProgressRepo
|
||||
}
|
||||
|
||||
func NewAudioProgressService(repo *repository.AudioProgressRepo) *AudioProgressService {
|
||||
return &AudioProgressService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *AudioProgressService) List(ctx context.Context, userID string) ([]model.AudioProgress, error) {
|
||||
return s.repo.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *AudioProgressService) Upsert(ctx context.Context, userID string, items []model.AudioProgress) error {
|
||||
return s.repo.Upsert(ctx, userID, items)
|
||||
}
|
||||
|
||||
func (s *AudioProgressService) Delete(ctx context.Context, userID, key string) error {
|
||||
return s.repo.Delete(ctx, userID, key)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS audio_progress;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Per-user audio playback resume points.
|
||||
--
|
||||
-- One row per (user, audio file). The floating audio player writes the
|
||||
-- last-known position for a track so it can resume there next time the
|
||||
-- same file is played, on any device the user is signed in on.
|
||||
--
|
||||
-- audio_key is the track URL with its query string / hash stripped
|
||||
-- (audio URLs may be signed or cache-busted, so the path is the stable
|
||||
-- identity). The schema is owned by the frontend; the backend only
|
||||
-- stores and round-trips it. position_seconds/duration_seconds mirror
|
||||
-- the HTMLAudioElement's currentTime/duration (floats).
|
||||
--
|
||||
-- updated_at is supplied by the client, not defaulted to now(), so the
|
||||
-- upsert can resolve multi-tab / multi-device conflicts with a
|
||||
-- newest-wins rule (see AudioProgressRepo.Upsert) and so anonymous
|
||||
-- IndexedDB entries keep their original timestamp when synced on login.
|
||||
CREATE TABLE IF NOT EXISTS audio_progress (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
audio_key TEXT NOT NULL,
|
||||
position_seconds DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
duration_seconds DOUBLE PRECISION,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, audio_key),
|
||||
CHECK (position_seconds >= 0)
|
||||
);
|
||||
Reference in New Issue
Block a user