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.
173 lines
6.4 KiB
Go
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
|
|
})
|
|
}
|