From 3576a4415a5ecc4a59551cd59e3071ac30fc19d0 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 22 Oct 2026 15:01:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(audio-player):=20per-book=20playback=20spe?= =?UTF-8?q?ed=20+=20user=20default=20(plan=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/audio_speed_test.go | 172 ++++++++++++++++++ database/schema/schema.sql | 23 +++ internal/database/models.go | 34 ++-- internal/database/querier.go | 4 + internal/database/queries.sql.go | 84 +++++++-- internal/database/queries/queries.sql | 17 ++ internal/handlers/auth.go | 39 +++- internal/handlers/media.go | 93 +++++++++- internal/router/helpers.go | 21 +-- internal/router/media.go | 1 + templates/audio_reader.templ | 12 +- templates/audio_reader_templ.go | 2 +- templates/profile_form.templ | 22 ++- templates/profile_form_templ.go | 250 ++++++++++++++++---------- templates/types.go | 7 + web/src/audio.ts | 47 +++++ 16 files changed, 680 insertions(+), 148 deletions(-) create mode 100644 cmd/server/tests/audio_speed_test.go diff --git a/cmd/server/tests/audio_speed_test.go b/cmd/server/tests/audio_speed_test.go new file mode 100644 index 00000000..de0dd886 --- /dev/null +++ b/cmd/server/tests/audio_speed_test.go @@ -0,0 +1,172 @@ +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 + }) +} diff --git a/database/schema/schema.sql b/database/schema/schema.sql index ad0f28ff..2cfe9f81 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS users ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); +-- User-level default audiobook playback speed (player plan §8): books +-- without a per-book audio_playback_speed row pick this up. REAL +-- mirrors the per-book table's column. +ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0; + -- Create system_settings table CREATE TABLE IF NOT EXISTS system_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -1563,6 +1568,24 @@ CREATE TABLE IF NOT EXISTS reading_speed ( CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id); CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id); +-- Per-user PER-BOOK playback speed preference (player plan §8): a +-- preference, NOT a measurement (reading_speed holds measured rates), +-- and NOT a reading_progress column (a speed tap must not create a +-- "currently reading" row). Resolution lives at the chapters endpoint: +-- per-book row → users.default_audio_speed → 1.0. +CREATE TABLE IF NOT EXISTS audio_playback_speed ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + speed REAL NOT NULL DEFAULT 1.0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, media_item_id) +); + +CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_user ON audio_playback_speed(user_id); +CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_item ON audio_playback_speed(media_item_id); + -- Dictionary cache (for offline use) CREATE TABLE IF NOT EXISTS dictionary_cache ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/internal/database/models.go b/internal/database/models.go index 759a8a05..c7f4aedc 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -8,6 +8,15 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type AudioPlaybackSpeed struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + Speed float32 `db:"speed" json:"speed"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` +} + type CollectionItems struct { ID pgtype.UUID `db:"id" json:"id"` CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` @@ -575,16 +584,17 @@ type UserDashboardPreferencesGlobal struct { } type Users struct { - ID pgtype.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - PasswordHash string `db:"password_hash" json:"password_hash"` - FirstName pgtype.Text `db:"first_name" json:"first_name"` - LastName pgtype.Text `db:"last_name" json:"last_name"` - Role string `db:"role" json:"role"` - Theme pgtype.Text `db:"theme" json:"theme"` - MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - Timezone pgtype.Text `db:"timezone" json:"timezone"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + ID pgtype.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + PasswordHash string `db:"password_hash" json:"password_hash"` + FirstName pgtype.Text `db:"first_name" json:"first_name"` + LastName pgtype.Text `db:"last_name" json:"last_name"` + Role string `db:"role" json:"role"` + Theme pgtype.Text `db:"theme" json:"theme"` + MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + Timezone pgtype.Text `db:"timezone" json:"timezone"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"` } diff --git a/internal/database/querier.go b/internal/database/querier.go index f794d044..112e3718 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -202,6 +202,8 @@ type Querier interface { GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) + // Per-book playback speed preference (player plan §8). + GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error) // Lifetime session rows for ONE book (the book-detail stats card). GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) @@ -696,12 +698,14 @@ type Querier interface { UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error // Update universal progress UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) + UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error + UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error) UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error) UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index bef22d15..da08a85a 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -3761,6 +3761,25 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF return items, nil } +const GetAudioPlaybackSpeed = `-- name: GetAudioPlaybackSpeed :one + +SELECT speed FROM audio_playback_speed +WHERE user_id = $1 AND media_item_id = $2 +` + +type GetAudioPlaybackSpeedParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +// Per-book playback speed preference (player plan §8). +func (q *Queries) GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error) { + row := q.db.QueryRow(ctx, GetAudioPlaybackSpeed, arg.UserID, arg.MediaItemID) + var speed float32 + err := row.Scan(&speed) + return speed, err +} + const GetBookReadingSessions = `-- name: GetBookReadingSessions :many SELECT rh.id, rh.device_id, rh.progress_percentage, @@ -9922,24 +9941,26 @@ SELECT u.created_at, u.updated_at, u.timezone, + u.default_audio_speed, (SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count FROM users u WHERE u.id = $1 ` type GetUserRow struct { - ID pgtype.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - Theme pgtype.Text `db:"theme" json:"theme"` - FirstName pgtype.Text `db:"first_name" json:"first_name"` - LastName pgtype.Text `db:"last_name" json:"last_name"` - Role string `db:"role" json:"role"` - MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` - Timezone pgtype.Text `db:"timezone" json:"timezone"` - DeviceCount int64 `db:"device_count" json:"device_count"` + ID pgtype.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + Theme pgtype.Text `db:"theme" json:"theme"` + FirstName pgtype.Text `db:"first_name" json:"first_name"` + LastName pgtype.Text `db:"last_name" json:"last_name"` + Role string `db:"role" json:"role"` + MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + Timezone pgtype.Text `db:"timezone" json:"timezone"` + DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"` + DeviceCount int64 `db:"device_count" json:"device_count"` } func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) { @@ -9957,6 +9978,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro &i.CreatedAt, &i.UpdatedAt, &i.Timezone, + &i.DefaultAudioSpeed, &i.DeviceCount, ) return i, err @@ -17200,9 +17222,23 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers return i, err } +const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec +UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1 +` + +type UpdateUserDefaultAudioSpeedParams struct { + ID pgtype.UUID `db:"id" json:"id"` + DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"` +} + +func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error { + _, err := q.db.Exec(ctx, UpdateUserDefaultAudioSpeed, arg.ID, arg.DefaultAudioSpeed) + return err +} + const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1 -RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at +RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed ` type UpdateUserMaxDevicesParams struct { @@ -17226,6 +17262,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev &i.CreatedAt, &i.Timezone, &i.UpdatedAt, + &i.DefaultAudioSpeed, ) return i, err } @@ -17316,6 +17353,27 @@ func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams) return err } +const UpsertAudioPlaybackSpeed = `-- name: UpsertAudioPlaybackSpeed :one +INSERT INTO audio_playback_speed (user_id, media_item_id, speed) +VALUES ($1, $2, $3) +ON CONFLICT (user_id, media_item_id) +DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW() +RETURNING speed +` + +type UpsertAudioPlaybackSpeedParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + Speed float32 `db:"speed" json:"speed"` +} + +func (q *Queries) UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error) { + row := q.db.QueryRow(ctx, UpsertAudioPlaybackSpeed, arg.UserID, arg.MediaItemID, arg.Speed) + var speed float32 + err := row.Scan(&speed) + return speed, err +} + const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section) VALUES ($1, $2, $3, $4, $5) diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 542f8a0d..b93ee152 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -28,6 +28,7 @@ SELECT u.created_at, u.updated_at, u.timezone, + u.default_audio_speed, (SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count FROM users u WHERE u.id = $1; @@ -511,6 +512,22 @@ RETURNING id, email, username, role; -- name: UpdateUserTimezone :exec UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1; +-- name: UpdateUserDefaultAudioSpeed :exec +UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1; + +-- Per-book playback speed preference (player plan §8). + +-- name: GetAudioPlaybackSpeed :one +SELECT speed FROM audio_playback_speed +WHERE user_id = $1 AND media_item_id = $2; + +-- name: UpsertAudioPlaybackSpeed :one +INSERT INTO audio_playback_speed (user_id, media_item_id, speed) +VALUES ($1, $2, $3) +ON CONFLICT (user_id, media_item_id) +DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW() +RETURNING speed; + -- name: GetSystemTimezone :one SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'; diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 5d86812f..a4377ac6 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "html/template" + "math" "net/http" "os" "path/filepath" @@ -120,6 +121,8 @@ type UserProfile struct { LastName string `json:"last_name"` Role string `json:"role"` Theme string `json:"theme,omitempty"` + // User-level default audiobook playback speed (plan §8); 0 omits. + DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"` } type UpdateProfileRequest struct { @@ -129,6 +132,9 @@ type UpdateProfileRequest struct { LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"` Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"` + // Per the SPEEDS list bounds; the profile select always sends it, + // JSON clients omit it (0 = untouched). + DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"` } type AdminUpdateUserRequest struct { @@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct { Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"` Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"` + // Same semantics as UpdateProfileRequest.DefaultAudioSpeed. + DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"` } // Register handles POST /api/auth/register @@ -538,13 +546,14 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error { lastName = user.LastName.String } return c.JSON(http.StatusOK, UserProfile{ - ID: uuid.UUID(user.ID.Bytes).String(), - Email: user.Email, - Username: user.Username, - FirstName: firstName, - LastName: lastName, - Role: user.Role, - Theme: user.Theme.String, + ID: uuid.UUID(user.ID.Bytes).String(), + Email: user.Email, + Username: user.Username, + FirstName: firstName, + LastName: lastName, + Role: user.Role, + Theme: user.Theme.String, + DefaultAudioSpeed: float64(user.DefaultAudioSpeed), }) } @@ -693,6 +702,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error { } } + // Update the default audiobook playback speed (plan §8). The form + // select always sends it; JSON clients that omit it decode as 0 and + // leave the column untouched. + if req.DefaultAudioSpeed != 0 { + if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"}) + } + err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{ + ID: targetUserUUID, + DefaultAudioSpeed: float32(req.DefaultAudioSpeed), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + } + // Browser path (htmx modal + profile page): refresh so every rendered // value (timezone select, table rows, header) reflects the save and the // modal closes. A bare JSON body swapped into #profile-result read as diff --git a/internal/handlers/media.go b/internal/handlers/media.go index ec9365ce..5b59bb1e 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "log" + "math" "mime/multipart" "net/http" "net/url" @@ -2990,16 +2991,96 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error { }) } + // Playback speed (plan §8): resolve per-book row → user default → + // 1.0 IN the payload so the player applies it with zero extra round + // trips; default_playback_speed powers the dropdown's "(default)" + // marker. Speed is a preference, so this rides the authenticated + // user — anonymous readers just get 1.0. + playbackSpeed, defaultSpeed := 1.0, 1.0 + if userID, ok := c.Get("user_id").(string); ok { + if userUUID, err := uuid.Parse(userID); err == nil { + userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) + if err == nil && userRow.DefaultAudioSpeed > 0 { + defaultSpeed = float64(userRow.DefaultAudioSpeed) + } + bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{ + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + }) + if err == nil && bookSpeed > 0 { + playbackSpeed = float64(bookSpeed) + } else { + playbackSpeed = defaultSpeed + } + } + } + return c.JSON(http.StatusOK, map[string]interface{}{ - "media_item_id": item.ID, - "chapters": payload.Chapters, - "duration_seconds": payload.DurationSeconds, - "codec_profile": payload.CodecProfile, - "layout": payload.Layout, - "tracks": tracks, + "media_item_id": item.ID, + "chapters": payload.Chapters, + "duration_seconds": payload.DurationSeconds, + "codec_profile": payload.CodecProfile, + "layout": payload.Layout, + "tracks": tracks, + "playback_speed": playbackSpeed, + "default_playback_speed": defaultSpeed, }) } +// SetAudioSpeed handles PUT /api/media-items/:id/audio-speed — the +// per-user per-book playback speed upsert (plan §8). The client SPEEDS +// list is the real gate; the server sanity-checks the range. +func (mh *MediaHandler) SetAudioSpeed(c *echo.Context) error { mediaID := c.Param("id") + mediaUUID, err := uuid.Parse(mediaID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) + } + userID, ok := c.Get("user_id").(string) + if !ok || userID == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + } + userUUID, err := uuid.Parse(userID) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user"}) + } + + var req struct { + Speed float64 `json:"speed"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if req.Speed < 0.25 || req.Speed > 4 || math.IsNaN(req.Speed) || math.IsInf(req.Speed, 0) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "speed out of range"}) + } + + // The book must exist (and be visible): a preference for a hidden + // library must not be writable — family-lens rule. + item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) + } + if item.LibraryID.Valid { + visibleSet, _, err := mh.visibleLibrarySet(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"}) + } + if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] { + return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"}) + } + } + + speed, err := mh.db.UpsertAudioPlaybackSpeed(c.Request().Context(), database.UpsertAudioPlaybackSpeedParams{ + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + Speed: float32(req.Speed), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save playback speed"}) + } + return c.JSON(http.StatusOK, map[string]float64{"playback_speed": float64(speed)}) +} + // GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the // per-track byte stream for multi-file audiobooks (folder books serve the // real file; zip books extract the entry in place). Library visibility is diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 9d7cfb4a..c10a3e10 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -56,16 +56,17 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err } return templates.User{ - ID: userID, - Email: userEmail, - Username: userUsername, - Role: userRole, - Theme: userTheme, - Token: token, - Timezone: userTimezone, - FirstName: utils.PgTextString(userDB.FirstName), - LastName: utils.PgTextString(userDB.LastName), - CreatedAt: userDB.CreatedAt.Time, + ID: userID, + Email: userEmail, + Username: userUsername, + Role: userRole, + Theme: userTheme, + Token: token, + Timezone: userTimezone, + FirstName: utils.PgTextString(userDB.FirstName), + LastName: utils.PgTextString(userDB.LastName), + CreatedAt: userDB.CreatedAt.Time, + DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed), }, nil } diff --git a/internal/router/media.go b/internal/router/media.go index 7b24ae98..2ccec3e6 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -22,6 +22,7 @@ func registerMediaRoutes(cfg *Config) { // books stream through the existing /uploads Range-serving path; // multi-file books additionally expose per-track streaming. protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters) + protected.PUT("/media-items/:id/audio-speed", cfg.MediaHandler.SetAudioSpeed) protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack) // Media rating routes (all authenticated users) diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index 4e0bc95b..f3951570 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -403,9 +403,19 @@ templ AudioReader(user User, metadata ReaderMetadata) { class="w-full text-left px-3 py-1.5 rounded-md text-sm" :class="Number(speed) === rate ? 'font-semibold' : ''" :style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'" - x-text="rate + '×'" + x-text="rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')" > + +
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

Shortcuts & interactions
Mouse & touch
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

—

0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

Shortcuts & interactions
Mouse & touch
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/profile_form.templ b/templates/profile_form.templ index fd0a4081..582c1748 100644 --- a/templates/profile_form.templ +++ b/templates/profile_form.templ @@ -1,5 +1,15 @@ package templates +import "fmt" + +// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default. +func effectiveAudioSpeed(user User) float64 { + if user.DefaultAudioSpeed == 0 { + return 1 + } + return user.DefaultAudioSpeed +} + templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
Material Dark +
+ + +
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Japan/Korea (UTC+9) ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showRoleField { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Admin") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -450,12 +506,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

Change Password

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

Change Password

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if requireCurrentPassword { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Update Password
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

As an admin, you can change this user's password without knowing their current password.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Reset Password
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showCancelButton { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Save Changes
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 8e77bdd9..628c1a4a 100644 --- a/templates/types.go +++ b/templates/types.go @@ -17,8 +17,15 @@ type User struct { CreatedAt time.Time Token string Timezone string + // User-level default audiobook playback speed (plan §8); 0 means + // "unset" and renders as the 1× option. + DefaultAudioSpeed float64 } +// SpeedOption is one entry of the default-playback-speed select — the +// server-side mirror of the web player's SPEEDS list (plan §5/§8). +var SpeedOptions = []float64{0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3} + type PageData struct { User User CurrentPage string diff --git a/web/src/audio.ts b/web/src/audio.ts index 32927f31..2f157c9c 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -196,6 +196,9 @@ Alpine.data("audioReader", () => ({ currentTime: 0, playing: false, speed: normalizeSpeed(localStorage.getItem("audio_speed")), + // User-level default speed (plan §8): powers the dropdown's + // "(default)" marker; hydrated from the chapters payload. + defaultSpeed: 1, // Exposed for the templ dropdown's x-for — templ cannot import the // const directly; this property is the binding. speeds: SPEEDS, @@ -501,6 +504,17 @@ Alpine.data("audioReader", () => ({ const data = await chaptersResp.json(); this.chapters = (data.chapters || []) as AudioChapter[]; this.bookDuration = data.duration_seconds || this.chapterEndEstimate(); + // §8: the server owns speed — per-book row, else the user's + // default. localStorage stays only as a first-paint cache; the + // payload value wins over it BEFORE the rate is (re)applied to + // the element below. + const serverSpeed = Number(data.playback_speed); + if (SPEEDS.includes(serverSpeed)) { + this.speed = serverSpeed; + localStorage.setItem("audio_speed", String(serverSpeed)); + } + const serverDefault = Number(data.default_playback_speed); + if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault; this.buildTracks(data); this.recomputeTicks(); const audio = this.audio(); @@ -624,6 +638,39 @@ Alpine.data("audioReader", () => ({ localStorage.setItem("audio_speed", String(rate)); const audio = this.audio(); if (audio) audio.playbackRate = rate; + // §8: the per-book speed lives server-side (localStorage is only a + // first-paint cache). Fire-and-forget; a failure toasts — the rate + // still applies to this session. + fetch(`/api/media-items/${this.mediaItemId}/audio-speed`, { + method: "PUT", + headers: { ...getAuthHeaders(), "content-type": "application/json" }, + body: JSON.stringify({ speed: rate }), + }) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + }) + .catch(() => showToast("Speed saved for this session only — server rejected it", "error")); + }, + + // setDefaultSpeed pins a user-level default (plan §8): books without + // their own per-book speed pick it up. Rides the profile endpoint, + // same as the profile-page select. + setDefaultSpeed(rate: number) { + fetch("/api/auth/profile", { + method: "PUT", + headers: { ...getAuthHeaders(), "content-type": "application/json" }, + body: JSON.stringify({ default_audio_speed: rate }), + }) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + this.defaultSpeed = rate; + showToast(`Default playback speed set to ${rate}×`, "success"); + }) + .catch(() => showToast("Default speed not saved", "error")); + }, + + isDefaultSpeed(rate: number): boolean { + return Number(this.defaultSpeed) === rate; }, setSkip(seconds: number) {