package media import ( "database/sql" "errors" "log/slog" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/home" "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. // // The home screen is not registered here: it is an aggregate over items, not an // item resource, and it lives in internal/home. func (h *Handlers) Register(mux *http.ServeMux) { 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("POST /api/v1/items/{id}/view", h.auth.Require(h.handleRecordView)) 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. f, err := catalog.FileByID(r.Context(), h.db, it.ID, fileID) if errors.Is(err, catalog.ErrNotFound) { 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(f.Path)) 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}) } // handleRecordView increments the user's view count for an item. // // The counting SQL lives in internal/home, which owns the shelves that read // these numbers back; only the item-scoped route stays here. func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) { it, ok := h.authorized(w, r, u) if !ok { return } views, err := home.RecordView(r.Context(), h.db, u.ID, it.ID) if err != nil { h.fail(w, "record view", err) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"item_id": it.ID, "views": views}) } // handleAuthors returns the people credited on an item. func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) { it, ok := h.authorized(w, r, u) if !ok { return } authors, err := h.authors(r, it.ID) if err != nil { h.fail(w, "authors", err) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors}) } // ── helpers ──────────────────────────────────────────────────────────── // inClause builds "?,?,?" plus the matching args for an IN list. // // Duplicated from internal/home on purpose: a browse list is an item resource, // and importing the home-screen package to borrow a query-string helper would // invert the dependency for three lines. func inClause(ids []int64) (string, []any) { placeholders := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } return strings.Join(placeholders, ","), args } // authorized loads an item and confirms the caller may read its library. // On failure it has already written the response. // // The load and the access check both live in catalog.LoadFor, so this package // no longer carries its own copy of the "may this user read this item" rule. 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 := catalog.LoadFor(r.Context(), h.db, u, id) if errors.Is(err, catalog.ErrNotFound) { httpx.WriteError(w, http.StatusNotFound, "item not found") return Item{}, false } if err != nil { h.fail(w, "load item", err) return Item{}, false } return it, true } // files returns an item's physical files with their ffprobe codec facts. // // The shape is catalog.File, whose JSON tags are frozen by MediaFile in // web/src/lib/api.ts. The server reports what is IN the file and never // transcodes; each client decides whether it can direct-play it. func (h *Handlers) files(r *http.Request, itemID int64) ([]catalog.File, error) { return catalog.Files(r.Context(), h.db, itemID) } 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) }