Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f317da348 | ||
|
|
3460678b6b | ||
|
|
bde7fc80b3 | ||
|
|
ce6e85ddb9 | ||
|
|
165b0205ae | ||
|
|
bbc421a809 | ||
|
|
399807a6fe | ||
|
|
b16e014dc6 | ||
|
|
5e43be2278 | ||
|
|
edc01ef733 |
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 && <> · {audiobook.year}</>}
|
||||
{durationTotal > 0 && <> · {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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user