The app looked like an admin panel. This is the product people actually see when they open their library. - Home: a landing screen with a Continue shelf (progress bars, straight from the progress table) and per-type shelves of recent additions. One request feeds it, so it does not pop in piece by piece. - Browse: cover grid per media type with type chips, plus browse-all. Powered by a new GET /items that lists across every visible library, filtered by kind, through the same access chokepoint. - Item page: cover, kind, title, authors, progress, a primary action that says Continue when you have started, description, and the file list for multi-file items. Cards now open the item page rather than jumping straight into a reader. - Search: results grouped by media type, cover-forward. - Navigation moved from "libraries" to "media types" — you browse Ebooks, not "the Ebooks folder". Libraries became an admin concern. - Design system: dark by default, cover-forward, one accent, tighter type scale, horizontal shelves, skeleton loaders. - media: GET /home, GET /items, and authors on item detail. Verified by screenshot in a live instance: home shelves, browse grid, and item page all render correctly.
352 lines
9.6 KiB
Go
352 lines
9.6 KiB
Go
package media
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/httpx"
|
|
"github.com/mithral/vellum/internal/progress"
|
|
"github.com/mithral/vellum/internal/stream"
|
|
)
|
|
|
|
// Handlers serves the generic per-item endpoints.
|
|
type Handlers struct {
|
|
db *sql.DB
|
|
log *slog.Logger
|
|
auth *auth.Authenticator
|
|
cacheDir string
|
|
}
|
|
|
|
// NewHandlers builds the media HTTP layer. cacheDir is where covers and
|
|
// extracted pages are written.
|
|
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir string) *Handlers {
|
|
return &Handlers{db: db, log: log, auth: a, cacheDir: cacheDir}
|
|
}
|
|
|
|
// Register mounts the routes.
|
|
func (h *Handlers) Register(mux *http.ServeMux) {
|
|
mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome))
|
|
mux.Handle("GET /api/v1/items", h.auth.Require(h.handleListItems))
|
|
mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem))
|
|
mux.Handle("GET /api/v1/items/{id}/authors", h.auth.Require(h.handleAuthors))
|
|
mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile))
|
|
mux.Handle("GET /api/v1/items/{id}/files/{fileID}", h.auth.Require(h.handleFileByID))
|
|
mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover))
|
|
mux.Handle("GET /api/v1/items/{id}/progress", h.auth.Require(h.handleGetProgress))
|
|
mux.Handle("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress))
|
|
mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent))
|
|
}
|
|
|
|
func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
it, ok := h.authorized(w, r, u)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
files, err := h.files(r, it.ID)
|
|
if err != nil {
|
|
h.fail(w, "list files", err)
|
|
return
|
|
}
|
|
|
|
prog, has, err := progress.Get(r.Context(), h.db, u.ID, it.ID)
|
|
if err != nil {
|
|
h.fail(w, "get progress", err)
|
|
return
|
|
}
|
|
|
|
authors, err := h.authors(r, it.ID)
|
|
if err != nil {
|
|
h.fail(w, "authors", err)
|
|
return
|
|
}
|
|
|
|
out := map[string]any{
|
|
"item": map[string]any{
|
|
"id": it.ID,
|
|
"library_id": it.LibraryID,
|
|
"kind": it.Kind,
|
|
"path": it.Path,
|
|
"title": it.Title,
|
|
"year": it.Year,
|
|
"missing": it.Missing,
|
|
},
|
|
"files": files,
|
|
"has_cover": h.hasCover(r, it),
|
|
"authors": authors,
|
|
}
|
|
if has {
|
|
out["progress"] = prog
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// authors returns the credited people for an item.
|
|
func (h *Handlers) authors(r *http.Request, itemID int64) ([]string, error) {
|
|
rows, err := h.db.QueryContext(r.Context(), `
|
|
SELECT p.name FROM item_people ip
|
|
JOIN people p ON p.id = ip.person_id
|
|
WHERE ip.item_id = ? ORDER BY ip.ord`, itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []string{}
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (h *Handlers) handleFile(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
it, ok := h.authorized(w, r, u)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Toolbar download vs inline. Inline is the default for readers.
|
|
disposition := "inline"
|
|
if r.URL.Query().Get("download") == "1" {
|
|
disposition = "attachment"
|
|
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
|
|
if err != nil || !allowed {
|
|
httpx.WriteError(w, http.StatusForbidden, "download not permitted")
|
|
return
|
|
}
|
|
}
|
|
w.Header().Set("Content-Disposition", disposition+`; filename="`+sanitizeFilename(it.Path)+`"`)
|
|
|
|
if err := stream.ServeFile(w, r, it.AbsPath()); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
httpx.WriteError(w, http.StatusNotFound, "file is not available")
|
|
return
|
|
}
|
|
if errors.Is(err, stream.ErrNotAFile) {
|
|
httpx.WriteError(w, http.StatusBadRequest, "item is not a file")
|
|
return
|
|
}
|
|
h.fail(w, "serve file", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// handleFileByID streams one specific file of an item, for multi-file items
|
|
// such as audiobook chapters or album tracks.
|
|
func (h *Handlers) handleFileByID(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
it, ok := h.authorized(w, r, u)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
fileID, err := strconv.ParseInt(r.PathValue("fileID"), 10, 64)
|
|
if err != nil || fileID <= 0 {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid file id")
|
|
return
|
|
}
|
|
|
|
// Confirm the file belongs to this item, so an item id cannot be paired
|
|
// with another item's file id.
|
|
var relPath string
|
|
err = h.db.QueryRowContext(r.Context(),
|
|
`SELECT path FROM media_files WHERE id = ? AND item_id = ?`, fileID, it.ID).Scan(&relPath)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httpx.WriteError(w, http.StatusNotFound, "file not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
h.fail(w, "load file", err)
|
|
return
|
|
}
|
|
|
|
abs := filepath.Join(it.RootPath, filepath.FromSlash(relPath))
|
|
w.Header().Set("Content-Disposition", "inline")
|
|
if err := stream.ServeFile(w, r, abs); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
httpx.WriteError(w, http.StatusNotFound, "file is not available")
|
|
return
|
|
}
|
|
h.fail(w, "serve file", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) handleCover(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
it, ok := h.authorized(w, r, u)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path, found, err := CoverPath(r.Context(), h.db, it, h.cacheDir)
|
|
if err != nil {
|
|
h.fail(w, "cover", err)
|
|
return
|
|
}
|
|
if !found {
|
|
httpx.WriteError(w, http.StatusNotFound, "no cover available")
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
if err := stream.ServeFile(w, r, path); err != nil {
|
|
h.fail(w, "serve cover", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) handleGetProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
it, ok := h.authorized(w, r, u)
|
|
if !ok {
|
|
return
|
|
}
|
|
prog, found, err := progress.Get(r.Context(), h.db, u.ID, it.ID)
|
|
if err != nil {
|
|
h.fail(w, "get progress", err)
|
|
return
|
|
}
|
|
if !found {
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": nil})
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": prog})
|
|
}
|
|
|
|
func (h *Handlers) handlePutProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
it, ok := h.authorized(w, r, u)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
FileID int64 `json:"file_id"`
|
|
Position *int64 `json:"position"`
|
|
Duration int64 `json:"duration"`
|
|
Percent float64 `json:"percent"`
|
|
Finished bool `json:"finished"`
|
|
}
|
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
p := progress.Progress{
|
|
ItemID: it.ID,
|
|
FileID: req.FileID,
|
|
Kind: it.Kind,
|
|
Duration: req.Duration,
|
|
Percent: req.Percent,
|
|
Finished: req.Finished,
|
|
}
|
|
if req.Position != nil {
|
|
p.Position = *req.Position
|
|
}
|
|
|
|
saved, err := progress.Put(r.Context(), h.db, u.ID, p)
|
|
if errors.Is(err, progress.ErrNoFile) {
|
|
httpx.WriteError(w, http.StatusConflict, "item has no file to track progress against")
|
|
return
|
|
}
|
|
if err != nil {
|
|
h.fail(w, "save progress", err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": saved})
|
|
}
|
|
|
|
func (h *Handlers) handleRecent(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
items, err := progress.RecentlyUpdated(r.Context(), h.db, u.ID, 20)
|
|
if err != nil {
|
|
h.fail(w, "recent progress", err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": items})
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────
|
|
|
|
// authorized loads an item and confirms the caller may read its library.
|
|
// On failure it has already written the response.
|
|
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (Item, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
|
|
return Item{}, false
|
|
}
|
|
|
|
it, err := LoadItem(r.Context(), h.db, id)
|
|
if errors.Is(err, ErrItemNotFound) {
|
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
|
return Item{}, false
|
|
}
|
|
if err != nil {
|
|
h.fail(w, "load item", err)
|
|
return Item{}, false
|
|
}
|
|
|
|
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
|
|
if err != nil {
|
|
h.fail(w, "check access", err)
|
|
return Item{}, false
|
|
}
|
|
if !allowed {
|
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
|
return Item{}, false
|
|
}
|
|
return it, true
|
|
}
|
|
|
|
func (h *Handlers) files(r *http.Request, itemID int64) ([]FileEntry, error) {
|
|
rows, err := h.db.QueryContext(r.Context(), `
|
|
SELECT id, path, size, container, missing FROM media_files WHERE item_id = ? ORDER BY id`, itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []FileEntry{}
|
|
for rows.Next() {
|
|
var f FileEntry
|
|
var missing int
|
|
if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &missing); err != nil {
|
|
return nil, err
|
|
}
|
|
f.Missing = missing == 1
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (h *Handlers) hasCover(r *http.Request, it Item) bool {
|
|
var n int
|
|
_ = h.db.QueryRowContext(r.Context(),
|
|
`SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&n)
|
|
return n > 0
|
|
}
|
|
|
|
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
|
h.log.Error(what, "err", err)
|
|
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
|
}
|
|
|
|
// sanitizeFilename strips characters that break a Content-Disposition header.
|
|
func sanitizeFilename(name string) string {
|
|
out := make([]rune, 0, len(name))
|
|
for _, r := range name {
|
|
switch {
|
|
case r == '"' || r == '\\' || r == '\n' || r == '\r':
|
|
out = append(out, '_')
|
|
default:
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|