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
|
authH *handler.AuthHandler
|
||||||
oauthH *handler.OAuthHandler
|
oauthH *handler.OAuthHandler
|
||||||
userH *handler.UserHandler
|
userH *handler.UserHandler
|
||||||
|
audioH *handler.AudioProgressHandler
|
||||||
postH *handler.PostHandler
|
postH *handler.PostHandler
|
||||||
commentH *handler.CommentHandler
|
commentH *handler.CommentHandler
|
||||||
bookmarkH *handler.BookmarkHandler
|
bookmarkH *handler.BookmarkHandler
|
||||||
@@ -194,6 +195,11 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||||
s.userH = handler.NewUserHandler(userSvc)
|
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
|
// OAuth — wired only when at least one provider is configured. The
|
||||||
// registry skips providers with missing creds, so partial configs (e.g.
|
// registry skips providers with missing creds, so partial configs (e.g.
|
||||||
// Google only, no Facebook) work out of the box.
|
// Google only, no Facebook) work out of the box.
|
||||||
@@ -706,6 +712,13 @@ func (s *Server) setupRouter() {
|
|||||||
// opaque JSON object.
|
// opaque JSON object.
|
||||||
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
||||||
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
|
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