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.
216 lines
6.4 KiB
Go
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)
|
|
}
|