Compare commits

..
Author SHA1 Message Date
RXWatcher 5f317da348 fix(audiobooks): address native API review comments 2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 3460678b6b feat(audiobooks): wire navigation and routes
Adds an Audiobooks entry to the sidebar and registers the two new
routes (/audiobooks for the library grid, /audiobooks/book/:id for
detail). The player renders inline inside the detail page; no
dedicated player route is required for MVP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 bde7fc80b3 feat(audiobooks): HTML5 audio player with chapter navigation
Single-file audiobook playback for MVP. Multi-file queuing arrives in
a follow-up. Streams via the existing /api/v1/direct-download GET
endpoint. Position is reported to /api/v1/audiobooks/{id}/progress
every 10s while playing plus on pause/seek/end. Skip-30s, playback
rate select, chapter list panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 ce6e85ddb9 feat(audiobooks): detail page with chapter list
Renders cover, title, author, narrator, year, and overview alongside a
chapter list. Clicking a chapter opens an inline sticky
AudiobookPlayer at that chapter's start. A "Resume" button restarts
playback at the saved progress position if present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 165b0205ae feat(audiobooks): library grid page at /audiobooks
Renders a paginated grid of audiobook cards using the
useAudiobookLibrary hook. Each card links to /audiobooks/book/{id}.
Cards show poster, title, and year; falls back to a "No cover"
placeholder when the audiobook has no poster_url. Empty state hints
to operators that they need to set a library's type to 'audiobooks'.

Routes themselves are wired in Task 8 (navigation integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 bbc421a809 feat(audiobooks): frontend types and TanStack Query hooks
TypeScript types match the JSON shapes from the new
/api/v1/audiobooks endpoints (list, detail, progress). Three hooks:
useAudiobookLibrary (list), useAudiobook (detail), and
useReportAudiobookProgress (mutation that invalidates the detail
query on success so progress updates reflect immediately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 399807a6fe feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress
UPSERTs user_watch_progress for the caller's (user_id, profile_id,
content_id). Body carries position_seconds; clients are expected to
post every 5-10s during playback plus on pause/seek (matching silo's
existing video progress cadence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 b16e014dc6 feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}
Returns the media_items row, its media_files (with chapters JSONB),
author/narrator extracted from item_people (kinds 7/8), and the
caller's per-profile listening progress from user_watch_progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 5e43be2278 feat(audiobooks): list endpoint at GET /api/v1/audiobooks
Paginated list of media_items with type='audiobook' scoped to the
caller's accessible libraries via the existing access filter.
Mirrors silo's existing list-style handlers for movies and series.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
RXWatcherandClaude Opus 4.7 edc01ef733 docs(audiobooks): implementation plan sub-plan 3 (API + frontend MVP)
Third of six sub-plans. 9 tasks: three REST endpoints (list/detail/
progress), TanStack Query hooks + types, three React pages
(Library/Detail/Player), and navigation integration. Scoped to MVP —
author/series indices, smart collections, share links, and other
nice-to-haves from the spec are deferred. Streaming reuses silo's
existing /api/v1/stream/{session_id}; no new transcode code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:49:19 +02:00
15 changed files with 1569 additions and 0 deletions
@@ -0,0 +1,326 @@
# Audiobooks Absorption — Sub-plan 3: Silo-native API + Frontend (MVP)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Land the minimum viable silo-native audiobooks UI: backend REST endpoints + React pages so a user can browse audiobook libraries scanned by sub-plan 2, see book details with chapters, play with progress saved. Author/series index pages, smart collections, share links, and other features from the spec's "nice-to-haves" list are deferred to a future sub-plan if the demand surfaces.
**Architecture:** Three new Go handler files under `internal/api/handlers/audiobooks_*.go` for list/detail/progress. New `web/src/pages/audiobooks/` directory with three pages: Library, Detail, and an inline player. Hooks under `web/src/hooks/audiobooks/`. The streaming path reuses silo's existing `/api/v1/stream/{session_id}` endpoint — no new transcode code.
**Tech stack:** Go + chi router + pgx for backend; React 19 + TanStack Query + radix-ui + tailwind for frontend.
**Source spec:** `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
**Predecessor:** `docs/superpowers/plans/2026-05-24-audiobooks-absorption-2-scanner.md` (scanner produces `media_items.type='audiobook'`)
---
## File Structure
| Path | C/M | Purpose |
|---|---|---|
| `internal/api/handlers/audiobooks_list.go` | C | `HandleListAudiobooks` — GET /api/v1/audiobooks (paginated, filterable by library) |
| `internal/api/handlers/audiobooks_detail.go` | C | `HandleGetAudiobook` — GET /api/v1/audiobooks/{id} (returns item + chapters + author/narrator + listening progress) |
| `internal/api/handlers/audiobooks_progress.go` | C | `HandleReportAudiobookProgress` — POST /api/v1/audiobooks/{id}/progress |
| `internal/api/router.go` | M | Mount three new routes under `/api/v1/audiobooks/*` |
| `web/src/hooks/audiobooks/useAudiobookLibrary.ts` | C | TanStack Query hook for list |
| `web/src/hooks/audiobooks/useAudiobook.ts` | C | Detail + progress hooks |
| `web/src/pages/audiobooks/AudiobookLibrary.tsx` | C | Grid page (poster + title + author) |
| `web/src/pages/audiobooks/AudiobookDetail.tsx` | C | Detail page (cover, metadata, chapter list, play button) |
| `web/src/pages/audiobooks/AudiobookPlayer.tsx` | C | HTML5 `<audio>` player with chapter nav + progress reporting |
| `web/src/App.tsx` or routing entry | M | Add the three new routes |
| `web/src/components/Sidebar.tsx` (or equivalent) | M | Add "Audiobooks" navigation link when at least one audiobook library exists |
---
## Task 1: Backend — list audiobooks
GET /api/v1/audiobooks?library_id=N&limit=...&offset=... → paginated audiobook list scoped to libraries the user has access to.
**Files:** Create `internal/api/handlers/audiobooks_list.go`, modify `internal/api/router.go`.
### 1.1 Test
Add `internal/api/handlers/audiobooks_list_test.go` with a test that:
- Inserts two `media_items` rows (one `type='audiobook'`, one `type='movie'`)
- Calls the handler with a fake request
- Asserts only the audiobook is returned
If no test harness exists for handler-level tests in `internal/api/handlers/`, search neighbors (`grep -lE 'func.*Handler.*Test' internal/api/handlers/*_test.go`) for the established pattern. If integration testing requires a real DB and no harness exists, write the handler logic + a simple compile-time test only; defer the integration test (DONE_WITH_CONCERNS).
### 1.2 Handler
```go
package handlers
import (
"encoding/json"
"net/http"
"strconv"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type AudiobookHandler struct {
Items *catalog.ItemRepository
}
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
filter := apimw.AccessFilterFor(r)
items, total, err := h.Items.Search(r.Context(), "", []string{"audiobook"}, limit, offset, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
return
}
resp := struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}{
Items: itemSummariesAsMaps(items),
Total: total,
Limit: limit,
Offset: offset,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func itemSummariesAsMaps(items []*models.MediaItem) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, it := range items {
out = append(out, map[string]any{
"content_id": it.ContentID,
"title": it.Title,
"year": it.Year,
"poster_url": it.PosterPath, // silo's poster column; UI prefixes with media-token base URL
})
}
return out
}
```
Verify `catalog.ItemRepository.Search` matches the signature above by reading `internal/catalog/item_repo.go:739`. Adjust if needed.
### 1.3 Route + commit
In `internal/api/router.go`, find where other v1 routes are mounted and add:
```go
r.Get("/audiobooks", audiobookHandler.HandleListAudiobooks)
```
Wire the handler construction near other handler constructors. The handler needs `catalog.ItemRepository` — already available in the router's deps.
Commit: `feat(audiobooks): list endpoint at GET /api/v1/audiobooks`.
---
## Task 2: Backend — audiobook detail
GET /api/v1/audiobooks/{id} → media_item + media_files (with chapters) + author/narrator from item_people + per-profile listening progress.
### 2.1 Handler
Create `internal/api/handlers/audiobooks_detail.go`. The handler queries:
1. `media_items WHERE content_id = $1 AND type = 'audiobook'`
2. `media_files WHERE content_id = $1` (returns paths + chapters JSONB)
3. `item_people JOIN people ON ... WHERE content_id = $1 AND kind IN (7, 8)`
4. `user_watch_progress WHERE content_id = $1 AND profile_id = $current`
Reuse existing repo methods where they exist. If not, add inline SQL.
Response shape:
```json
{
"audiobook": { "content_id": "...", "title": "...", "year": 2024, "overview": "...", "poster_url": "..." },
"author": "Test Author",
"narrator": "Test Narrator",
"files": [
{ "id": 123, "path": "/...", "duration_seconds": 3600, "chapters": [ {"index": 0, "title": "Intro", "start_seconds": 0, "end_seconds": 27.9} ] }
],
"progress": { "position_seconds": 1842.5, "updated_at": "2026-05-24T..." }
}
```
### 2.2 Route + commit
In `router.go`: `r.Get("/audiobooks/{id}", audiobookHandler.HandleGetAudiobook)`.
Commit: `feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}`.
---
## Task 3: Backend — progress endpoint
POST /api/v1/audiobooks/{id}/progress with body `{"position_seconds": 1842.5, "media_file_id": 123}`. UPSERTs `user_watch_progress`.
### 3.1 Handler
Create `internal/api/handlers/audiobooks_progress.go`. Body is JSON; handler parses, validates `position_seconds >= 0`, then upserts into `user_watch_progress` keyed on `(user_id, profile_id, media_item_id)`.
Reuse silo's existing progress-write helper if one exists (grep `INSERT INTO user_watch_progress` or `UpsertWatchProgress`). If not, write inline SQL using the existing DB pool from handler deps.
### 3.2 Route + commit
`r.Post("/audiobooks/{id}/progress", audiobookHandler.HandleReportAudiobookProgress)`.
Commit: `feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress`.
---
## Task 4: Frontend — TanStack Query hooks + types
**Files:**
- Create: `web/src/lib/audiobooks/types.ts`
- Create: `web/src/hooks/audiobooks/useAudiobookLibrary.ts`
- Create: `web/src/hooks/audiobooks/useAudiobook.ts`
- Create: `web/src/hooks/audiobooks/useReportAudiobookProgress.ts`
### 4.1 Types
```ts
// web/src/lib/audiobooks/types.ts
export interface AudiobookSummary {
content_id: string;
title: string;
year: number;
poster_url: string | null;
}
export interface AudiobookListResponse {
items: AudiobookSummary[];
total: number;
limit: number;
offset: number;
}
export interface AudiobookChapter {
index: number;
title: string;
start_seconds: number;
end_seconds: number;
}
export interface AudiobookFile {
id: number;
path: string;
duration_seconds: number;
chapters: AudiobookChapter[];
}
export interface AudiobookProgress {
position_seconds: number;
updated_at: string;
}
export interface AudiobookDetail {
audiobook: { content_id: string; title: string; year: number; overview: string; poster_url: string | null };
author: string;
narrator: string;
files: AudiobookFile[];
progress: AudiobookProgress | null;
}
```
### 4.2 Hooks
Use TanStack Query patterns silo already uses (grep `useQuery` for examples). Cache keys: `["audiobooks", "library", libraryId]` and `["audiobook", id]`. Progress mutation invalidates the detail query on success.
### 4.3 Commit
Commit: `feat(audiobooks): frontend types and TanStack Query hooks`.
---
## Task 5: Frontend — Audiobook Library page
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookLibrary.tsx`
Grid of audiobook cards (poster + title + author). Use silo's existing card / grid components — grep `web/src/components/` for `MediaCard` or similar. Infinite scroll via `useInfiniteQuery` would be ideal but a simple paginated load-more is fine for MVP.
Route URL: `/audiobooks` (or `/audiobooks/library/:id` if there are multiple libraries). For MVP, single library route `/audiobooks` is fine.
Commit: `feat(audiobooks): library grid page`.
---
## Task 6: Frontend — Audiobook Detail page
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookDetail.tsx`
Layout: cover image (poster) + metadata (title, author, year, overview) + chapter list + play button. Chapter list is collapsible; clicking a chapter starts playback at that chapter's start_seconds.
Route URL: `/audiobooks/book/:id`.
Commit: `feat(audiobooks): detail page with chapter list`.
---
## Task 7: Frontend — Audiobook Player
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookPlayer.tsx` (or `web/src/player/AudiobookPlayer.tsx`)
HTML5 `<audio>` element. The stream URL for an audiobook file is silo's existing `/api/v1/stream/{session_id}` — call POST /api/v1/playback/session to start a session (existing silo endpoint), then point the `<audio src>` at the stream URL. Position updates throttled to every 5-10 seconds + on pause/seek.
Controls: play/pause, skip-30s-forward, skip-30s-back, playback rate select (0.5×, 0.75×, 1×, 1.25×, 1.5×, 2×, 3×), chapter list panel.
If silo's existing stream-session-start endpoint shape isn't obvious, grep for `playback/session` and copy the pattern from the video player.
Commit: `feat(audiobooks): HTML5 audio player with chapter navigation`.
---
## Task 8: Navigation + routing integration
**Files:**
- Modify: `web/src/App.tsx` (or wherever routes are registered) — add three new routes
- Modify: `web/src/components/Sidebar.tsx` (or equivalent) — add "Audiobooks" nav link
The sidebar link should only show when at least one audiobook library exists. Use a `useAudiobookLibrary` query with a short staleTime to gate visibility, OR a separate `useLibrariesOfKind("audiobooks")` hook.
Commit: `feat(audiobooks): wire navigation and routes`.
---
## Task 9: Build, lint, smoke
```bash
cd /opt/silo-server
go build ./...
go test ./internal/api/handlers/audiobooks_*_test.go ./internal/scanner/... ./internal/models/... ./internal/audiobooks/...
go vet ./...
cd web && pnpm run lint && pnpm run format:check && cd ..
make build
```
Rebuild silo image + force-recreate container:
```bash
sudo docker build -t silo:latest /opt/silo-server
sudo docker compose -p silo-prod up -d --force-recreate silo
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
curl -sS -o /dev/null -w "GET /api/v1/audiobooks -> %{http_code}\n" http://localhost:8090/api/v1/audiobooks
```
If any sweep changes (gofmt, prettier) were made, commit them with `chore(audiobooks): sweep formatting`.
---
## Risks
- Frontend tasks depend on silo's existing component shapes. Subagents will need to grep `web/src/components/` for the right patterns. If they invent ad-hoc components, ask them to use existing ones.
- The progress endpoint UPSERT shape depends on `user_watch_progress` PK shape — which discovery D4 confirmed is `(user_id, profile_id, content_id)`. Confirm and use exactly that ON CONFLICT target.
- Audiobook libraries may not yet exist on the test stack. To smoke-test, an operator must manually update a `media_folders.type` to `'audiobooks'` and trigger a rescan after the sub-plan 2 scanner branch lands.
+167
View File
@@ -0,0 +1,167 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/models"
)
// HandleGetAudiobook serves GET /api/v1/audiobooks/{id}. Returns the
// audiobook media_item, its media_files (with embedded chapters),
// author/narrator from item_people (kinds 7/8), and the caller's
// per-profile listening progress.
func (h *AudiobookHandler) HandleGetAudiobook(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
return
}
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "load audiobook failed")
return
}
if len(items) == 0 {
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
return
}
item := items[0]
if item == nil || item.Type != "audiobook" {
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
return
}
files, err := h.Files.GetByContentID(r.Context(), contentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
return
}
author, narrator, err := h.fetchAuthorNarrator(r, contentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "load people failed")
return
}
progress := h.fetchProgress(r, contentID)
resp := audiobookDetailResponse{
Audiobook: audiobookDetailItem{
ContentID: item.ContentID,
Title: item.Title,
Year: item.Year,
Overview: item.Overview,
PosterURL: h.presignAudiobookPoster(r.Context(), item.PosterPath),
},
Author: author,
Narrator: narrator,
Files: audiobookDetailFiles(files),
Progress: progress,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// fetchAuthorNarrator retrieves the first author (kind=7) and narrator (kind=8)
// name from item_people for the given content ID.
func (h *AudiobookHandler) fetchAuthorNarrator(r *http.Request, contentID string) (author, narrator string, err error) {
people, err := h.Items.GetPeople(r.Context(), contentID)
if err != nil {
return "", "", err
}
for _, p := range people {
switch p.Kind {
case models.PersonKindAuthor:
if author == "" {
author = p.Name
}
case models.PersonKindNarrator:
if narrator == "" {
narrator = p.Name
}
}
if author != "" && narrator != "" {
break
}
}
return author, narrator, nil
}
// fetchProgress returns the caller's listening progress for this audiobook, or
// nil when no progress exists or the caller is not authenticated with a profile.
func (h *AudiobookHandler) fetchProgress(r *http.Request, contentID string) *audiobookDetailProgress {
if h.StoreProvider == nil {
return nil
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
return nil
}
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
if profileID == "" {
return nil
}
store, err := h.StoreProvider.ForUser(r.Context(), userID)
if err != nil || store == nil {
return nil
}
wp, err := store.GetProgress(r.Context(), profileID, contentID)
if err != nil || wp == nil {
return nil
}
return &audiobookDetailProgress{
PositionSeconds: wp.PositionSeconds,
Completed: wp.Completed,
UpdatedAt: wp.UpdatedAt,
}
}
type audiobookDetailResponse struct {
Audiobook audiobookDetailItem `json:"audiobook"`
Author string `json:"author,omitempty"`
Narrator string `json:"narrator,omitempty"`
Files []audiobookDetailFile `json:"files"`
Progress *audiobookDetailProgress `json:"progress,omitempty"`
}
type audiobookDetailItem struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Year int `json:"year"`
Overview string `json:"overview,omitempty"`
PosterURL string `json:"poster_url,omitempty"`
}
type audiobookDetailFile struct {
ID int `json:"id"`
Path string `json:"path"`
DurationSeconds int `json:"duration_seconds"`
Chapters []models.MediaChapter `json:"chapters,omitempty"`
}
type audiobookDetailProgress struct {
PositionSeconds float64 `json:"position_seconds"`
Completed bool `json:"completed"`
UpdatedAt string `json:"updated_at"`
}
func audiobookDetailFiles(files []*models.MediaFile) []audiobookDetailFile {
out := make([]audiobookDetailFile, 0, len(files))
for _, f := range files {
out = append(out, audiobookDetailFile{
ID: f.ID,
Path: f.FilePath,
DurationSeconds: f.Duration,
Chapters: f.Chapters,
})
}
return out
}
+83
View File
@@ -0,0 +1,83 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strconv"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// AudiobookHandler serves silo-native audiobook endpoints under
// /api/v1/audiobooks/*. Sub-plan 3 implements list/detail/progress;
// sub-plan 4's ABS-compat layer is separate.
type AudiobookHandler struct {
Items *catalog.ItemRepository
Files *scanner.FileRepository
Detail *catalog.DetailService
StoreProvider userstore.UserStoreProvider
}
// HandleListAudiobooks serves GET /api/v1/audiobooks?limit=&offset=.
// Paginated catalog of media_items with type='audiobook' scoped to the
// caller's accessible libraries.
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
filter := requestAccessFilter(r)
items, total, err := h.Items.Search(r.Context(), "", []string{"audiobook"}, limit, offset, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
return
}
resp := struct {
Items []audiobookListItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}{
Items: h.audiobookListItems(r.Context(), items),
Total: total,
Limit: limit,
Offset: offset,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
type audiobookListItem struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Year int `json:"year"`
PosterURL string `json:"poster_url,omitempty"`
}
func (h *AudiobookHandler) audiobookListItems(ctx context.Context, items []*models.MediaItem) []audiobookListItem {
out := make([]audiobookListItem, 0, len(items))
for _, it := range items {
out = append(out, audiobookListItem{
ContentID: it.ContentID,
Title: it.Title,
Year: it.Year,
PosterURL: h.presignAudiobookPoster(ctx, it.PosterPath),
})
}
return out
}
func (h *AudiobookHandler) presignAudiobookPoster(ctx context.Context, path string) string {
if h == nil || h.Detail == nil || path == "" {
return ""
}
return h.Detail.PresignURL(ctx, cardThumbnailPath(path), "card")
}
@@ -0,0 +1,113 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type audiobookProgressRequest struct {
PositionSeconds float64 `json:"position_seconds"`
MediaFileID int `json:"media_file_id,omitempty"`
}
// HandleReportAudiobookProgress serves POST /api/v1/audiobooks/{id}/progress.
// UPSERTs into user_watch_progress for the caller's (user_id, profile_id, content_id).
func (h *AudiobookHandler) HandleReportAudiobookProgress(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
return
}
var req audiobookProgressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid request body")
return
}
if req.PositionSeconds < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "position_seconds must be >= 0")
return
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
if userID == 0 || profileID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
if h.Items == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "catalog unavailable")
return
}
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "check access failed")
return
}
if len(items) == 0 || items[0] == nil || items[0].Type != "audiobook" {
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
return
}
durationSeconds, err := h.audiobookProgressDuration(r.Context(), contentID, req.MediaFileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
return
}
if h.StoreProvider == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
return
}
store, err := h.StoreProvider.ForUser(r.Context(), userID)
if err != nil || store == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
return
}
if err := store.SetProgress(r.Context(), profileID, contentID, req.PositionSeconds, durationSeconds, userstore.ProgressThresholds{}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "save progress failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *AudiobookHandler) audiobookProgressDuration(ctx context.Context, contentID string, mediaFileID int) (float64, error) {
if h == nil || h.Files == nil {
return 0, nil
}
files, err := h.Files.GetByContentID(ctx, contentID)
if err != nil {
return 0, err
}
return audiobookDurationForProgress(files, mediaFileID), nil
}
func audiobookDurationForProgress(files []*models.MediaFile, mediaFileID int) float64 {
var total int
for _, f := range files {
if f == nil || f.Duration <= 0 {
continue
}
if mediaFileID > 0 && f.ID == mediaFileID {
return float64(f.Duration)
}
total += f.Duration
}
if total <= 0 {
return 0
}
return float64(total)
}
+41
View File
@@ -0,0 +1,41 @@
package handlers
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestAudiobookListItemsDoNotExposeRawPosterKeysWithoutPresigner(t *testing.T) {
h := &AudiobookHandler{}
items := h.audiobookListItems(context.Background(), []*models.MediaItem{{
ContentID: "book-1",
Title: "Book",
PosterPath: "metadata/audiobooks/book-1/poster/original.webp",
}})
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
if items[0].PosterURL != "" {
t.Fatalf("PosterURL = %q, want empty without presigner", items[0].PosterURL)
}
}
func TestAudiobookDurationForProgress(t *testing.T) {
files := []*models.MediaFile{
{ID: 10, Duration: 120},
{ID: 11, Duration: 180},
}
if got := audiobookDurationForProgress(files, 10); got != 120 {
t.Fatalf("selected file duration = %v, want 120", got)
}
if got := audiobookDurationForProgress(files, 0); got != 300 {
t.Fatalf("total duration = %v, want 300", got)
}
if got := audiobookDurationForProgress([]*models.MediaFile{{ID: 1}}, 1); got != 0 {
t.Fatalf("unknown duration = %v, want 0", got)
}
}
+13
View File
@@ -1787,6 +1787,19 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/sections/recipes", recipeHandler.HandleList)
r.Get("/sections/recipes/{type}/candidates", recipeHandler.HandleCandidates)
// Audiobook endpoints (no profile required — catalog-level list/detail).
if itemRepo != nil {
audiobookHandler := &handlers.AudiobookHandler{
Items: itemRepo,
Files: deps.FileRepo,
Detail: detailSvc,
StoreProvider: deps.UserStoreProvider,
}
r.Get("/audiobooks", audiobookHandler.HandleListAudiobooks)
r.Get("/audiobooks/{id}", audiobookHandler.HandleGetAudiobook)
r.Post("/audiobooks/{id}/progress", audiobookHandler.HandleReportAudiobookProgress)
}
// Section endpoints (profile-scoped).
if sectionHandler != nil {
r.Group(func(r chi.Router) {
+4
View File
@@ -87,6 +87,8 @@ import WatchTogetherJoin from "@/pages/WatchTogetherJoin";
import WatchTogetherRoomPage from "@/pages/WatchTogetherRoomPage";
import WatchRoute from "@/pages/WatchRoute";
import ProfileCustomizeHome from "@/pages/ProfileCustomizeHome";
import AudiobookLibrary from "@/pages/audiobooks/AudiobookLibrary";
import AudiobookDetail from "@/pages/audiobooks/AudiobookDetail";
import {
WatchPlaybackBar,
WatchPlaybackHost,
@@ -522,6 +524,8 @@ function AppRoutes() {
element={<RecommendationsSection />}
/>
<Route path="/calendar" element={<Calendar />} />
<Route path="/audiobooks" element={<AudiobookLibrary />} />
<Route path="/audiobooks/book/:contentId" element={<AudiobookDetail />} />
<Route
path="/profile/customize-home"
element={<ProfileCustomizeHome />}
+18
View File
@@ -52,6 +52,7 @@ import {
LayoutGrid,
Puzzle,
Send,
BookHeadphones,
} from "lucide-react";
import { useTheme } from "@/hooks/useTheme";
import { CURATED_THEME_IDS, THEMES } from "@/lib/themes";
@@ -547,6 +548,23 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
<SidebarLabel show={showLabels}>Calendar</SidebarLabel>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to="/audiobooks"
onClick={onNavigate}
className={navLinkClass("/audiobooks")}
aria-current={isActive("/audiobooks") ? "page" : undefined}
>
{isActive("/audiobooks") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<BookHeadphones className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Audiobooks</SidebarLabel>
</ViewTransitionLink>
</li>
</ul>
</div>
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/api/client";
import type { AudiobookDetailResponse } from "@/lib/audiobooks/types";
export function useAudiobook(contentId: string | undefined) {
return useQuery<AudiobookDetailResponse>({
queryKey: ["audiobooks", "detail", contentId],
enabled: Boolean(contentId),
queryFn: () => api<AudiobookDetailResponse>(`/audiobooks/${contentId}`),
});
}
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/api/client";
import type { AudiobookListResponse } from "@/lib/audiobooks/types";
interface UseAudiobookLibraryOpts {
limit?: number;
offset?: number;
enabled?: boolean;
}
export function useAudiobookLibrary(opts: UseAudiobookLibraryOpts = {}) {
const { limit = 50, offset = 0, enabled = true } = opts;
return useQuery<AudiobookListResponse>({
queryKey: ["audiobooks", "list", limit, offset],
enabled,
queryFn: () => {
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return api<AudiobookListResponse>(`/audiobooks?${params.toString()}`);
},
});
}
@@ -0,0 +1,32 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
interface ReportProgressVars {
contentId: string;
positionSeconds: number;
mediaFileId?: number;
}
interface ReportProgressBody {
position_seconds: number;
media_file_id?: number;
}
export function useReportAudiobookProgress() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contentId, positionSeconds, mediaFileId }: ReportProgressVars) => {
const body: ReportProgressBody = { position_seconds: positionSeconds };
if (mediaFileId !== undefined) {
body.media_file_id = mediaFileId;
}
return api(`/audiobooks/${contentId}/progress`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: ["audiobooks", "detail", vars.contentId] });
},
});
}
+50
View File
@@ -0,0 +1,50 @@
export interface AudiobookSummary {
content_id: string;
title: string;
year: number;
poster_url?: string;
}
export interface AudiobookListResponse {
items: AudiobookSummary[];
total: number;
limit: number;
offset: number;
}
export interface AudiobookChapter {
index: number;
title: string;
source: string;
start_seconds: number;
end_seconds: number;
}
export interface AudiobookFile {
id: number;
path: string;
duration_seconds: number;
chapters?: AudiobookChapter[];
}
export interface AudiobookProgress {
position_seconds: number;
updated_at: string;
completed?: boolean;
}
export interface AudiobookDetailItem {
content_id: string;
title: string;
year: number;
overview?: string;
poster_url?: string;
}
export interface AudiobookDetailResponse {
audiobook: AudiobookDetailItem;
author?: string;
narrator?: string;
files: AudiobookFile[];
progress?: AudiobookProgress;
}
@@ -0,0 +1,285 @@
import { useState } from "react";
import { useParams } from "react-router";
import { useAudiobook } from "@/hooks/audiobooks/useAudiobook";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronRight, Play, BookHeadphones } from "lucide-react";
import AudiobookPlayer from "./AudiobookPlayer";
import type { AudiobookChapter, AudiobookFile } from "@/lib/audiobooks/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatSeconds(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "";
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = Math.floor(totalSeconds % 60);
if (h > 0) {
return `${h}h ${String(m).padStart(2, "0")}m`;
}
if (m > 0) {
return `${m}m ${String(s).padStart(2, "0")}s`;
}
return `${s}s`;
}
function totalDuration(files: AudiobookFile[]): number {
return files.reduce((acc, f) => acc + (f.duration_seconds ?? 0), 0);
}
/** Gather all chapters across files, adjusting start/end by each file's cumulative offset. */
function buildChapterList(files: AudiobookFile[]): Array<{
chapter: AudiobookChapter;
absoluteStart: number;
fileId: number;
label: string;
}> {
const result: Array<{
chapter: AudiobookChapter;
absoluteStart: number;
fileId: number;
label: string;
}> = [];
let offset = 0;
for (const file of files) {
if (file.chapters && file.chapters.length > 0) {
for (const ch of file.chapters) {
result.push({
chapter: ch,
absoluteStart: offset + ch.start_seconds,
fileId: file.id,
label: ch.title || `Chapter ${ch.index + 1}`,
});
}
}
offset += file.duration_seconds ?? 0;
}
return result;
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface ChapterListProps {
files: AudiobookFile[];
onSelect: (absoluteSeconds: number) => void;
}
function ChapterList({ files, onSelect }: ChapterListProps) {
const [expanded, setExpanded] = useState(true);
const chapters = buildChapterList(files);
if (chapters.length === 0) return null;
return (
<div className="mt-10">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mb-4 flex items-center gap-1.5 text-xl font-semibold tracking-tight"
>
{expanded ? (
<ChevronDown className="h-5 w-5 opacity-60" />
) : (
<ChevronRight className="h-5 w-5 opacity-60" />
)}
Chapters
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
({chapters.length})
</span>
</button>
{expanded && (
<ol className="divide-border divide-y rounded-xl border">
{chapters.map(({ chapter, absoluteStart, label }, i) => (
<li key={`${chapter.index}-${i}`}>
<button
type="button"
onClick={() => onSelect(absoluteStart)}
className="hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors"
>
<span className="text-muted-foreground w-6 shrink-0 text-right text-xs tabular-nums">
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{label}</span>
<span className="text-muted-foreground shrink-0 font-mono text-xs">
{formatSeconds(absoluteStart)}
</span>
<Play className="text-muted-foreground h-3.5 w-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
</li>
))}
</ol>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Loading skeleton
// ---------------------------------------------------------------------------
function AudiobookDetailSkeleton() {
return (
<div className="page-shell py-8">
<div className="flex flex-col gap-8 sm:flex-row">
<Skeleton className="aspect-[2/3] w-full rounded-xl sm:w-[200px] sm:shrink-0 md:w-[260px]" />
<div className="flex flex-1 flex-col gap-3">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="mt-4 h-24 w-full" />
<Skeleton className="mt-6 h-10 w-32" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function AudiobookDetail() {
const { contentId } = useParams<{ contentId: string }>();
const { data, isLoading, error } = useAudiobook(contentId);
const [playerOpen, setPlayerOpen] = useState(false);
const [startSeconds, setStartSeconds] = useState(0);
if (isLoading && !data) {
return <AudiobookDetailSkeleton />;
}
if (error || !data) {
return (
<div className="page-shell py-8">
<div className="text-destructive py-16 text-center text-sm">
{error instanceof Error ? error.message : "Failed to load audiobook."}
</div>
</div>
);
}
const { audiobook, author, narrator, files, progress } = data;
const hasProgress = Boolean(progress && progress.position_seconds > 0 && !progress.completed);
const resumeSeconds = progress?.position_seconds ?? 0;
const durationTotal = totalDuration(files);
function openPlayer(atSeconds: number) {
setStartSeconds(atSeconds);
setPlayerOpen(true);
}
function handlePlayResume() {
openPlayer(hasProgress ? resumeSeconds : 0);
}
return (
<div>
{/* Sticky inline player */}
{playerOpen && (
<div className="bg-background sticky top-0 z-30 border-b">
<AudiobookPlayer
contentId={contentId ?? ""}
files={files}
initialPositionSeconds={startSeconds}
onClose={() => setPlayerOpen(false)}
/>
</div>
)}
<div className="page-shell py-8">
{/* Hero row */}
<div className="flex flex-col gap-8 sm:flex-row">
{/* Cover */}
<div className="w-full shrink-0 sm:w-[200px] md:w-[260px]">
<div className="aspect-[2/3] overflow-hidden rounded-xl">
{audiobook.poster_url ? (
<img
src={audiobook.poster_url}
alt={audiobook.title}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-muted text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-3 p-4 text-center">
<BookHeadphones className="h-16 w-16 opacity-30" />
<span className="line-clamp-3 text-sm font-medium">{audiobook.title}</span>
</div>
)}
</div>
</div>
{/* Info */}
<div className="flex flex-1 flex-col">
{/* Eyebrow */}
<p className="text-muted-foreground mb-2 text-xs font-semibold tracking-[0.2em] uppercase">
Audiobook
{audiobook.year > 0 && <> &middot; {audiobook.year}</>}
{durationTotal > 0 && <> &middot; {formatSeconds(durationTotal)}</>}
</p>
{/* Title */}
<h1 className="mb-1 text-3xl leading-tight font-bold tracking-tight">
{audiobook.title}
</h1>
{/* Author / narrator */}
{author && (
<p className="text-muted-foreground mt-1 text-sm">
<span className="font-medium">By</span> {author}
</p>
)}
{narrator && (
<p className="text-muted-foreground mt-0.5 text-sm">
<span className="font-medium">Narrated by</span> {narrator}
</p>
)}
{/* Overview */}
{audiobook.overview && (
<p className="mt-5 max-w-prose text-sm leading-relaxed">{audiobook.overview}</p>
)}
{/* Actions */}
{files.length > 0 && (
<div className="mt-6 flex items-center gap-3">
<Button onClick={handlePlayResume} className="gap-2">
<Play className="h-4 w-4" />
{hasProgress ? "Resume" : "Play"}
</Button>
{hasProgress && (
<Button variant="outline" onClick={() => openPlayer(0)}>
Play from Start
</Button>
)}
</div>
)}
{/* Progress bar */}
{hasProgress && durationTotal > 0 && (
<div className="mt-4">
<div className="bg-muted h-1.5 w-full max-w-xs overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-all"
style={{ width: `${Math.min(100, (resumeSeconds / durationTotal) * 100)}%` }}
/>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{formatSeconds(resumeSeconds)} listened
</p>
</div>
)}
</div>
</div>
{/* Chapter list */}
{files.length > 0 && <ChapterList files={files} onSelect={(s) => openPlayer(s)} />}
</div>
</div>
);
}
@@ -0,0 +1,123 @@
import { useState } from "react";
import ScrollToTopButton from "@/components/ScrollToTopButton";
import { Skeleton } from "@/components/ui/skeleton";
import ViewTransitionLink from "@/components/ViewTransitionLink";
import { useAudiobookLibrary } from "@/hooks/audiobooks/useAudiobookLibrary";
const PAGE_SIZE = 60;
const GRID_CLASSES =
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
export default function AudiobookLibrary() {
const [offset, setOffset] = useState(0);
const { data, isLoading, error } = useAudiobookLibrary({ limit: PAGE_SIZE, offset });
if (isLoading && !data) {
return (
<div className="space-y-5 py-2 sm:space-y-6">
<h1 className="text-3xl font-semibold">Audiobooks</h1>
<div role="list" className={GRID_CLASSES}>
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} role="listitem">
<Skeleton className="aspect-[2/3] rounded-xl" />
<Skeleton className="mt-2 h-4 w-3/4" />
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-5 py-2 sm:space-y-6">
<h1 className="text-3xl font-semibold">Audiobooks</h1>
<div className="text-destructive py-12 text-center">
Failed to load audiobooks: {error instanceof Error ? error.message : "Unknown error"}
</div>
</div>
);
}
const items = data?.items ?? [];
const total = data?.total ?? 0;
return (
<div className="space-y-5 py-2 sm:space-y-6">
<h1 className="text-3xl font-semibold">Audiobooks</h1>
{items.length === 0 ? (
<div className="text-muted-foreground py-12 text-center">
No audiobooks indexed yet. Set a library&apos;s type to{" "}
<code className="text-foreground bg-muted rounded px-1 py-0.5 text-sm">audiobooks</code>{" "}
and rescan.
</div>
) : (
<div role="list" className={GRID_CLASSES}>
{items.map((item) => (
<div key={item.content_id} role="listitem" className="media-card group/card">
<ViewTransitionLink
to={`/audiobooks/book/${item.content_id}`}
className="block overflow-hidden rounded-xl"
>
<div className="media-card-image relative aspect-[2/3]">
{item.poster_url ? (
<img
src={item.poster_url}
alt={item.title}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-1 p-3 text-center text-sm">
<span className="line-clamp-3 font-medium">{item.title || "No Cover"}</span>
</div>
)}
<div className="from-background/70 pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t to-transparent opacity-90" />
</div>
</ViewTransitionLink>
<ViewTransitionLink
to={`/audiobooks/book/${item.content_id}`}
className="block px-1 pt-3"
>
<div className="truncate text-[14px] font-semibold tracking-tight">
{item.title}
</div>
{item.year > 0 && (
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
{item.year}
</div>
)}
</ViewTransitionLink>
</div>
))}
</div>
)}
{total > PAGE_SIZE && (
<div className="mt-8 flex justify-center gap-2">
<button
type="button"
className="rounded-md border px-4 py-2 text-sm disabled:opacity-50"
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
>
Previous
</button>
<span className="text-muted-foreground self-center text-sm">
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
type="button"
className="rounded-md border px-4 py-2 text-sm disabled:opacity-50"
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={offset + PAGE_SIZE >= total}
>
Next
</button>
</div>
)}
<ScrollToTopButton />
</div>
);
}
@@ -0,0 +1,279 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { buildDirectDownloadUrl } from "@/hooks/queries/downloads";
import { useReportAudiobookProgress } from "@/hooks/audiobooks/useReportAudiobookProgress";
import type { AudiobookFile } from "@/lib/audiobooks/types";
import { Button } from "@/components/ui/button";
import { X, Play, Pause, RotateCcw, RotateCw } from "lucide-react";
export interface AudiobookPlayerProps {
contentId: string;
files: AudiobookFile[];
initialPositionSeconds?: number;
onClose?: () => void;
}
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
const PLAYBACK_RATES = [0.75, 1, 1.25, 1.5, 2] as const;
const REPORT_INTERVAL_MS = 10_000;
export default function AudiobookPlayer({
contentId,
files,
initialPositionSeconds = 0,
onClose,
}: AudiobookPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(initialPositionSeconds);
const [duration, setDuration] = useState(0);
const [rate, setRate] = useState(1);
const { mutate: reportProgress } = useReportAudiobookProgress();
const lastReportedRef = useRef<number>(initialPositionSeconds);
const reportTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const file = files[0];
const isMultiFile = files.length > 1;
// Build the stream URL once so it doesn't change on re-render.
const streamUrl = file ? buildDirectDownloadUrl(file.id) : "";
// Report helper — fire-and-forget, won't throw.
const report = useCallback(
(posSeconds: number) => {
if (!file) return;
lastReportedRef.current = posSeconds;
reportProgress({
contentId,
positionSeconds: Math.floor(posSeconds),
mediaFileId: file.id,
});
},
[contentId, file, reportProgress],
);
// Set initial position once audio is ready.
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handle = () => {
if (initialPositionSeconds > 0 && audio.duration > 0) {
audio.currentTime = Math.min(initialPositionSeconds, audio.duration - 1);
}
};
audio.addEventListener("loadedmetadata", handle);
return () => audio.removeEventListener("loadedmetadata", handle);
}, [initialPositionSeconds]);
// Wire audio element events.
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
const onDurationChange = () => setDuration(audio.duration ?? 0);
const onPlay = () => setPlaying(true);
const onPause = () => {
setPlaying(false);
report(audio.currentTime);
};
const onSeeked = () => report(audio.currentTime);
const onEnded = () => {
setPlaying(false);
report(audio.currentTime);
};
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("durationchange", onDurationChange);
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
audio.addEventListener("seeked", onSeeked);
audio.addEventListener("ended", onEnded);
return () => {
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("durationchange", onDurationChange);
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("seeked", onSeeked);
audio.removeEventListener("ended", onEnded);
};
}, [report]);
// Periodic progress reporting while playing.
useEffect(() => {
if (playing) {
reportTimerRef.current = setInterval(() => {
const audio = audioRef.current;
if (audio) report(audio.currentTime);
}, REPORT_INTERVAL_MS);
} else {
if (reportTimerRef.current) {
clearInterval(reportTimerRef.current);
reportTimerRef.current = null;
}
}
return () => {
if (reportTimerRef.current) {
clearInterval(reportTimerRef.current);
reportTimerRef.current = null;
}
};
}, [playing, report]);
// Report and pause on unmount.
useEffect(() => {
const audio = audioRef.current;
return () => {
if (audio && !audio.paused) {
audio.pause();
report(audio.currentTime);
}
};
}, [report]);
function togglePlay() {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
void audio.play();
} else {
audio.pause();
}
}
function skip(delta: number) {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = Math.max(0, Math.min(audio.currentTime + delta, audio.duration ?? 0));
}
function handleRateChange(e: React.ChangeEvent<HTMLSelectElement>) {
const r = Number(e.target.value);
setRate(r);
if (audioRef.current) audioRef.current.playbackRate = r;
}
function handleSeek(e: React.ChangeEvent<HTMLInputElement>) {
const val = Number(e.target.value);
setCurrentTime(val);
if (audioRef.current) audioRef.current.currentTime = val;
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="flex items-center gap-3 px-4 py-3">
{/* Hidden audio element */}
{file && (
<audio ref={audioRef} src={streamUrl} preload="metadata" style={{ display: "none" }} />
)}
{/* Close */}
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close player"
className="h-8 w-8 shrink-0"
>
<X className="h-4 w-4" />
</Button>
)}
{/* Multi-file notice */}
{isMultiFile && (
<span className="text-muted-foreground text-xs">
Multi-file playback coming soon — playing first file only.
</span>
)}
{/* Controls */}
<div className="flex min-w-0 flex-1 flex-col gap-2">
{/* Top row: skip-back, play/pause, skip-forward */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => skip(-30)}
aria-label="Skip back 30 seconds"
className="h-8 w-8"
disabled={!file}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="default"
size="icon"
onClick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
className="h-9 w-9 shrink-0"
disabled={!file}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => skip(30)}
aria-label="Skip forward 30 seconds"
className="h-8 w-8"
disabled={!file}
>
<RotateCw className="h-4 w-4" />
</Button>
{/* Time */}
<span className="text-muted-foreground shrink-0 font-mono text-xs tabular-nums">
{formatTime(currentTime)}
{" / "}
{formatTime(duration)}
</span>
{/* Playback rate */}
<select
value={rate}
onChange={handleRateChange}
aria-label="Playback speed"
className="bg-background text-muted-foreground hover:text-foreground focus:ring-ring ml-auto shrink-0 rounded border-0 px-1.5 py-1 text-xs outline-none focus:ring-1"
disabled={!file}
>
{PLAYBACK_RATES.map((r) => (
<option key={r} value={r}>
{r}×
</option>
))}
</select>
</div>
{/* Seek bar */}
<input
type="range"
min={0}
max={duration > 0 ? duration : 100}
step={1}
value={currentTime}
onChange={handleSeek}
aria-label="Seek"
disabled={!file || duration === 0}
className="accent-primary h-1 w-full cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) ${progress}%, hsl(var(--muted)) ${progress}%)`,
}}
/>
</div>
</div>
);
}