From 9c9c46caf18c6acfb99907d65cb6fdc36a5a07be Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Thu, 2 Jul 2026 19:22:54 +0530 Subject: [PATCH] feat: audio track history --- internal/handler/audio_progress.go | 98 +++++++++++++++++++ internal/model/audio_progress.go | 16 +++ internal/repository/audio_progress_repo.go | 87 ++++++++++++++++ internal/server/server.go | 13 +++ internal/service/audio_progress_service.go | 33 +++++++ .../0043_create_audio_progress.down.sql | 1 + migrations/0043_create_audio_progress.up.sql | 25 +++++ 7 files changed, 273 insertions(+) create mode 100644 internal/handler/audio_progress.go create mode 100644 internal/model/audio_progress.go create mode 100644 internal/repository/audio_progress_repo.go create mode 100644 internal/service/audio_progress_service.go create mode 100644 migrations/0043_create_audio_progress.down.sql create mode 100644 migrations/0043_create_audio_progress.up.sql diff --git a/internal/handler/audio_progress.go b/internal/handler/audio_progress.go new file mode 100644 index 0000000..332d936 --- /dev/null +++ b/internal/handler/audio_progress.go @@ -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=. 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) +} diff --git a/internal/model/audio_progress.go b/internal/model/audio_progress.go new file mode 100644 index 0000000..6e37979 --- /dev/null +++ b/internal/model/audio_progress.go @@ -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"` +} diff --git a/internal/repository/audio_progress_repo.go b/internal/repository/audio_progress_repo.go new file mode 100644 index 0000000..5f8c655 --- /dev/null +++ b/internal/repository/audio_progress_repo.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 387b239..ef2385e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) }) }) diff --git a/internal/service/audio_progress_service.go b/internal/service/audio_progress_service.go new file mode 100644 index 0000000..d90230d --- /dev/null +++ b/internal/service/audio_progress_service.go @@ -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) +} diff --git a/migrations/0043_create_audio_progress.down.sql b/migrations/0043_create_audio_progress.down.sql new file mode 100644 index 0000000..b0b12ef --- /dev/null +++ b/migrations/0043_create_audio_progress.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audio_progress; diff --git a/migrations/0043_create_audio_progress.up.sql b/migrations/0043_create_audio_progress.up.sql new file mode 100644 index 0000000..7ed1baa --- /dev/null +++ b/migrations/0043_create_audio_progress.up.sql @@ -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) +);