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 }) }