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' : '')" > + + —0:00 0:00 10 10 Skip 10s Speed 1× Bookmark Sleep Chapters No chapters detected for this audiobook.Bookmarks Cancel Save noteNo bookmarks yet — tap the button to save your position.Bookmark at 0:00Cancel SaveShortcuts & interactions Mouse & touch
—
No chapters detected for this audiobook.
No bookmarks yet — tap the button to save your position.
Bookmark at 0:00