// Package home computes what the landing screen shows: the five shelves and the // counts the sidebar needs. // // It is separated from internal/media because the two answer different // questions. Media serves item RESOURCES — one item, its file, its cover, its // progress. Home computes AGGREGATES over many items: what you were in the // middle of, what you keep opening, what to try next. Neither can be built from // the other, and a change to a shelf query has no business touching the endpoint // that streams a file. // // It depends on internal/catalog only for the shape of an item; the SQL is its // own because the shelves are the only queries in the server that aggregate // across libraries, kinds and users at once. package home import ( "database/sql" "log/slog" "net/http" "strings" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/httpx" ) // Handlers serves the home screen. type Handlers struct { db *sql.DB log *slog.Logger auth *auth.Authenticator } // NewHandlers builds the home-screen HTTP layer. func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers { return &Handlers{db: db, log: log, auth: a} } // Register mounts the routes. func (h *Handlers) Register(mux *http.ServeMux) { mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome)) } // 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) } 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") } 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 } // nowRFC3339 returns the canonical database timestamp for a write. func nowRFC3339() string { return core.Now() }