A book is a folder of chapters, not a file. The scanner gained a grouping
mode so a directory of tracks becomes one item with ordered files.
- registry: MediaType.Group (file | directory). Audiobooks and music group by
directory; everything else stays one item per file.
- scanner: builds groups, upserts one item per group, marks items missing when
their directory disappears, and keeps the file-level added/updated/missing
accounting. Tracks are ordered naturally, so chapter 2 precedes chapter 10.
- natsort: one shared natural-order comparator, now used by both the scanner
and the comics module (which had its own copy).
- media: GET /items/{id}/files/{fileID} streams one specific track. It
verifies the file belongs to the item, so an item id cannot be paired with
another item's file id.
No new module was needed for playback: the polymorphic progress table already
keys on file_id, so per-chapter resume fell out of the existing design. The
web player resumes the exact track and offset.
Verified: 3 chapters grouped into one book titled "Mistborn", natural order,
range request returned 206, mismatched file id 404s, per-track progress
round-trips.
320 lines
8.7 KiB
Go
320 lines
8.7 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/items/{id}", h.auth.Require(h.handleItem))
|
|
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
|
|
}
|
|
|
|
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),
|
|
}
|
|
if has {
|
|
out["progress"] = prog
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
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)
|
|
}
|