Files
bookhoard/cmd/server/tests/audio_speed_test.go
T
John O'Keefe 3576a4415a feat(audio-player): per-book playback speed + user default (plan §8)
Speed becomes a per-USER-per-BOOK preference that syncs across
devices, with a user-level default for books not yet played:
- new audio_playback_speed table (mirrors reading_speed's shape; a
  preference, NOT a measurement and NOT a reading_progress column) +
  users.default_audio_speed, schema in place
- chapters endpoint resolves per-book row → user default → 1.0 and
  ships playback_speed + default_playback_speed (zero extra round
  trips; default powers the dropdown marker)
- PUT /api/media-items/:id/audio-speed upserts (range-checked; the
  client SPEEDS list stays the real gate; library visibility gates
  writes)
- the user default rides the profile path like theme/timezone:
  profile form gains a 'Default Playback Speed' select (server-side
  SpeedOptions mirrors the web SPEEDS list), GET /profile carries it
- web: restorePosition applies the resolved server speed (localStorage
  audio_speed demotes to a first-paint cache); setSpeed PUTs
  fire-and-forget with a failure toast; the Speed dropdown gains a
  '(default)' row marker and a 'Make N× my default' action with a
  success toast

ALSO fixes a pre-existing profile-page bug surfaced by the §8 form
verification: the password section's current_password input was
required INSIDE the shared form, so HTML5 validation blocked every
Save Changes submission (dead since the shell migration). The
password flow keeps its own gates (disabled-button checkers + server
400).

Integration test TestAudioPlaybackSpeed pins the full contract (unset
→ 1x, upsert resolution, range rejection, default fallback for
row-less books, default updates re-satisfying unset books). Verified
live: 9 Playwright checks.
2026-10-22 15:01:41 -04:00

173 lines
6.4 KiB
Go

package main
// Playback speed preferences (player plan §8): a per-user PER-BOOK
// speed plus a user-level default, resolved at the chapters endpoint
// (per-book row → users.default_audio_speed → 1.0). All speed writes
// ride the fresh per-test regular user — the shared admin account is
// never touched (AGENTS doctrine). Fixture books live in test-named
// libraries that cascade away with the library delete.
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// writeTestTrackTone renders a short sine mp3 at a GIVEN frequency so
// multiple fixture books never collide on the scanner's content dedup.
// Falls back to dummy bytes (ingest is ffprobe-optional) like
// writeTestTrack.
func writeTestTrackTone(t *testing.T, path string, hz int) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
"-i", fmt.Sprintf("sine=frequency=%d:duration=0.3", hz), "-codec:a", "libmp3lame", "-b:a", "32k", path)
if out, err := cmd.CombinedOutput(); err == nil {
return
} else {
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
}
}
require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("ID3 dummy audio %d", hz)), 0o644))
}
func TestAudioPlaybackSpeed(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Speed %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Speed Book", "01 one.mp3"))
// 552 Hz (not the shared helper's 440) so the scanner's sha-256
// dedup doesn't skip the second book as a byte-duplicate.
writeTestTrackTone(t, filepath.Join(folder, "Speed Book Two", "01 one.mp3"), 552)
scanTestFolder(t, setup, admin, folder)
// Grouping depends on track count (single-track folders ingest as
// single-file items whose path IS the file; multi-track folders get
// the folder path) — find by the library-relative path CONTAINING
// the book folder so the test holds either way.
var bookPath, book2Path string
for _, item := range listLibraryAudioItems(t, setup, libID) {
p := filepath.ToSlash(item.FilePath)
t.Logf("ingested item path: %s", p)
if strings.Contains(p, "Speed Book Two") && book2Path == "" {
book2Path = p
} else if strings.Contains(p, "Speed Book/") && bookPath == "" {
bookPath = p
}
}
require.NotEmpty(t, bookPath, "Speed Book item must exist")
require.NotEmpty(t, book2Path, "Speed Book Two item must exist")
book, found := findAudioItemByPath(t, setup, libID, bookPath)
require.True(t, found)
bookID := uuid.UUID(book.ID.Bytes)
book2, found := findAudioItemByPath(t, setup, libID, book2Path)
require.True(t, found)
book2ID := uuid.UUID(book2.ID.Bytes)
client := &http.Client{}
token := setup.RegularToken
authed := func(method, url, body string) (*http.Response, func()) {
var req *http.Request
if body == "" {
req, _ = http.NewRequest(method, url, nil)
} else {
req, _ = http.NewRequest(method, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
return resp, func() { _ = resp.Body.Close() }
}
getSpeeds := func(itemID uuid.UUID) (playback, def float64) {
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var data struct {
PlaybackSpeed float64 `json:"playback_speed"`
DefaultPlaybackSpeed float64 `json:"default_playback_speed"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
return data.PlaybackSpeed, data.DefaultPlaybackSpeed
}
t.Run("unset resolves to 1x", func(t *testing.T) {
playback, def := getSpeeds(bookID)
require.Equal(t, 1.0, playback)
require.Equal(t, 1.0, def)
})
t.Run("put per-book speed upserts and resolves", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", `{"speed":1.5}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var saved struct {
PlaybackSpeed float64 `json:"playback_speed"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&saved))
require.Equal(t, 1.5, saved.PlaybackSpeed)
playback, def := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
require.Equal(t, 1.0, def) // user default untouched
})
t.Run("out of range speed is rejected", func(t *testing.T) {
for _, body := range []string{`{"speed":99}`, `{"speed":0}`, `{"speed":0.1}`} {
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", body)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
closer()
}
// The stored speed is untouched by the rejects.
playback, _ := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
})
t.Run("profile default applies to books without their own speed", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":1.25}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Per-book row wins over the default on book one...
playback, def := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
require.Equal(t, 1.25, def)
// ...book two has no row and picks the default up.
playback2, _ := getSpeeds(book2ID)
require.Equal(t, 1.25, playback2)
// GET /profile carries it for the profile form.
resp2, closer2 := authed("GET", setup.Server.URL+"/api/auth/profile", "")
defer closer2()
var profile struct {
DefaultAudioSpeed float64 `json:"default_audio_speed"`
}
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&profile))
require.Equal(t, 1.25, profile.DefaultAudioSpeed)
})
t.Run("updating the default re-satisfies unset books", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":0.75}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
playback2, _ := getSpeeds(book2ID)
require.Equal(t, 0.75, playback2)
playback1, _ := getSpeeds(bookID)
require.Equal(t, 1.5, playback1) // per-book pin survives
})
}