Vellum/internal/media/home.go
mithral 4c51482dd6 Home: five sections — Continue, Most Viewed, Suggestions, Recently Added, Explore
The landing screen now reads as a product rather than a list.

- migration 0006: item_views, per user. "Most viewed" is personal — what I
  re-read is not what you re-read. Counted when an item is opened in a reader,
  so the number means consumed, not merely fetched.
- Suggestions: unstarted items related to what the user has been consuming —
  same author first, then same kind, then recent as a floor. Each carries the
  reason it was chosen ("More by Daft Punk"), because a suggestion nobody can
  understand is noise.
- Explore: a mixed sample across every kind, excluding in-progress items so the
  zone is discovery rather than a repeat.
- Home fetches all five shelves in one request; the screen never pops in.
- web: Shelf gained subtitle/badge support, Card gained a subtitle, and the
  home renders four shelves plus a wrapping Explore grid.

Three bugs found and fixed while verifying:

- Continue showed a multi-track audiobook once per chapter. GROUP BY item_id
  with MAX(updated_at) picks one card per item (SQLite takes the bare columns
  from the row that produced the max).
- Suggestions and Explore passed their SQL arguments in the wrong order: the IN
  list precedes the user id in those queries, so a user id was being bound as a
  library id. Covered by a scoping test that would have caught it.
- The sidebar lost its media types on a cold, logged-out load. bootstrap()
  fetched media types inside a Promise.all with /auth/me, so the expected 401
  rejected the whole thing and nothing refetched after login. Media types are
  public and now load independently.
2026-10-10 16:59:39 -04:00

216 lines
6.4 KiB
Go

package media
import (
"database/sql"
"net/http"
"strings"
"time"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
)
// shelfItem is one card on the home screen.
type shelfItem struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Percent float64 `json:"percent"`
FileID int64 `json:"file_id"`
Views int `json:"views"`
UpdatedAt string `json:"updated_at"`
}
// suggestion is a recommendation with the reason it was made.
type suggestion struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Year int `json:"year"`
Reason string `json:"reason"`
}
// homeResponse is everything the landing screen needs in one request.
type homeResponse struct {
Continue []shelfItem `json:"continue"`
MostViewed []shelfItem `json:"most_viewed"`
Suggestions []suggestion `json:"suggestions"`
Recent []shelfItem `json:"recent"`
Explore []shelfItem `json:"explore"`
CountsByKind map[string]int `json:"counts_by_kind"`
LibraryCount int `json:"library_count"`
TotalItems int `json:"total_items"`
}
// handleHome powers the landing screen: five shelves plus the counts the
// sidebar needs.
//
// One request rather than six round trips, because the landing screen is the
// first thing a user sees and should not pop in piece by piece.
func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.User) {
ctx := r.Context()
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
resp := homeResponse{
Continue: []shelfItem{},
MostViewed: []shelfItem{},
Suggestions: []suggestion{},
Recent: []shelfItem{},
Explore: []shelfItem{},
CountsByKind: map[string]int{},
}
if len(libs) == 0 {
httpx.WriteJSON(w, http.StatusOK, resp)
return
}
resp.LibraryCount = len(libs)
in, args := inClause(libs)
// ── Continue: started but unfinished, one row per item ───────────────
//
// GROUP BY item_id with MAX(updated_at): SQLite takes the bare column
// values from the row that produced the max, so a multi-track audiobook
// contributes its most recent track rather than one card per chapter.
rows, err := h.db.QueryContext(ctx, `
SELECT i.id, i.library_id, i.kind, i.title, p.percent, p.file_id, 0,
MAX(p.updated_at) AS updated_at
FROM progress p
JOIN media_items i ON i.id = p.item_id
WHERE p.user_id = ? AND p.finished = 0 AND i.missing = 0
AND i.library_id IN (`+in+`)
GROUP BY p.item_id
ORDER BY updated_at DESC
LIMIT 20`, append([]any{u.ID}, args...)...)
if err != nil {
h.fail(w, "home: continue", err)
return
}
if resp.Continue, err = scanShelf(rows); err != nil {
h.fail(w, "home: scan continue", err)
return
}
// ── Most viewed: what this user actually opens ───────────────────────
if resp.MostViewed, err = MostViewed(ctx, h.db, u.ID, libs, 20); err != nil {
h.fail(w, "home: most viewed", err)
return
}
// ── Suggestions: related to what they have been consuming ────────────
if resp.Suggestions, err = Suggestions(ctx, h.db, u.ID, libs, 20); err != nil {
h.fail(w, "home: suggestions", err)
return
}
// ── Recently added ───────────────────────────────────────────────────
rows, err = h.db.QueryContext(ctx, `
SELECT i.id, i.library_id, i.kind, i.title, 0, 0, 0, i.added_at
FROM media_items i
WHERE i.missing = 0 AND i.library_id IN (`+in+`)
ORDER BY i.added_at DESC, i.id DESC
LIMIT 40`, args...)
if err != nil {
h.fail(w, "home: recent", err)
return
}
if resp.Recent, err = scanShelf(rows); err != nil {
h.fail(w, "home: scan recent", err)
return
}
// ── Explore: a mixed sample of everything else ───────────────────────
if resp.Explore, err = Explore(ctx, h.db, u.ID, libs, 30); err != nil {
h.fail(w, "home: explore", err)
return
}
// ── Counts per kind, for the sidebar and section headers ─────────────
rows, err = h.db.QueryContext(ctx, `
SELECT i.kind, count(*) FROM media_items i
WHERE i.missing = 0 AND i.library_id IN (`+in+`)
GROUP BY i.kind`, args...)
if err != nil {
h.fail(w, "home: counts", err)
return
}
defer rows.Close()
for rows.Next() {
var kind string
var n int
if err := rows.Scan(&kind, &n); err != nil {
h.fail(w, "home: scan counts", err)
return
}
resp.CountsByKind[kind] = n
resp.TotalItems += n
}
if err := rows.Err(); err != nil {
h.fail(w, "home: iterate counts", err)
return
}
httpx.WriteJSON(w, http.StatusOK, resp)
}
// handleRecordView increments the user's view count for an item.
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 := 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})
}
func scanShelf(rows *sql.Rows) ([]shelfItem, error) {
defer rows.Close()
out := []shelfItem{}
for rows.Next() {
var s shelfItem
if err := rows.Scan(&s.ID, &s.LibraryID, &s.Kind, &s.Title, &s.Percent, &s.FileID, &s.Views, &s.UpdatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// inClause builds "?,?,?" plus the matching args for an IN list.
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
}
func nowRFC3339() string {
return time.Now().UTC().Format(time.RFC3339Nano)
}