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.
This commit is contained in:
mithral 2026-10-10 16:59:39 -04:00
parent e21661460a
commit 4c51482dd6
11 changed files with 803 additions and 87 deletions

275
internal/media/discover.go Normal file
View file

@ -0,0 +1,275 @@
package media
import (
"context"
"database/sql"
"fmt"
"strings"
)
// RecordView increments a user's view count for an item.
//
// Called when an item is opened in a reader, so the count reflects consumption
// rather than a detail-page fetch. Opening the same thing repeatedly still
// counts: "most viewed" should reflect habit, not just distinct items.
func RecordView(ctx context.Context, db *sql.DB, userID, itemID int64) (int, error) {
var views int
err := db.QueryRowContext(ctx, `
INSERT INTO item_views (user_id, item_id, views, last_viewed_at)
VALUES (?, ?, 1, ?)
ON CONFLICT (user_id, item_id)
DO UPDATE SET views = views + 1, last_viewed_at = excluded.last_viewed_at
RETURNING views`,
userID, itemID, nowRFC3339()).Scan(&views)
if err != nil {
return 0, fmt.Errorf("media: record view: %w", err)
}
return views, nil
}
// MostViewed returns the user's most-opened items within the given libraries.
func MostViewed(ctx context.Context, db *sql.DB, userID int64, libraryIDs []int64, limit int) ([]shelfItem, error) {
if len(libraryIDs) == 0 {
return []shelfItem{}, nil
}
if limit <= 0 || limit > 100 {
limit = 20
}
in, args := inClause(libraryIDs)
args = append([]any{userID}, args...)
args = append(args, limit)
query := `
SELECT i.id, i.library_id, i.kind, i.title, v.views, v.last_viewed_at
FROM item_views v
JOIN media_items i ON i.id = v.item_id
WHERE v.user_id = ? AND i.missing = 0
AND i.library_id IN (` + in + `)
ORDER BY v.views DESC, v.last_viewed_at DESC
LIMIT ?`
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("media: most viewed: %w", err)
}
defer rows.Close()
out := []shelfItem{}
for rows.Next() {
var s shelfItem
if err := rows.Scan(&s.ID, &s.LibraryID, &s.Kind, &s.Title, &s.Views, &s.UpdatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// itemRef is a lightweight item used by the suggestion and explore queries.
type itemRef struct {
ID int64
LibraryID int64
Kind string
Title string
Year int
}
// engagement describes what a user has been consuming, which is the basis for
// suggestions.
type engagement struct {
Kind string
Title string
Author string
}
// topEngagement finds the kind and author the user most recently engaged with,
// so suggestions can be explained in plain language.
func topEngagement(ctx context.Context, db *sql.DB, userID int64) (engagement, bool, error) {
var e engagement
err := db.QueryRowContext(ctx, `
SELECT i.kind, i.title,
COALESCE((SELECT p.name FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = i.id AND ip.role = 'author'
ORDER BY ip.ord LIMIT 1), '')
FROM progress p
JOIN media_items i ON i.id = p.item_id
WHERE p.user_id = ? AND i.missing = 0
ORDER BY p.updated_at DESC
LIMIT 1`, userID).Scan(&e.Kind, &e.Title, &e.Author)
if err == sql.ErrNoRows {
return engagement{}, false, nil
}
if err != nil {
return engagement{}, false, fmt.Errorf("media: top engagement: %w", err)
}
return e, true, nil
}
// Suggestions returns unstarted items related to what the user has been
// consuming: same author first, then the same kind, then anything recent.
//
// The rule is deliberately legible — a suggestion a person cannot understand is
// just noise. Each result carries the reason it was chosen.
func Suggestions(ctx context.Context, db *sql.DB, userID int64, libraryIDs []int64, limit int) ([]suggestion, error) {
if len(libraryIDs) == 0 {
return []suggestion{}, nil
}
if limit <= 0 || limit > 50 {
limit = 20
}
e, hasEngagement, err := topEngagement(ctx, db, userID)
if err != nil {
return nil, err
}
in, inArgs := inClause(libraryIDs)
out := []suggestion{}
seen := map[int64]bool{}
add := func(rows *sql.Rows, reason string) error {
defer rows.Close()
for rows.Next() {
var r itemRef
if err := rows.Scan(&r.ID, &r.LibraryID, &r.Kind, &r.Title, &r.Year); err != nil {
return err
}
if seen[r.ID] || len(out) >= limit {
continue
}
seen[r.ID] = true
out = append(out, suggestion{
ID: r.ID, LibraryID: r.LibraryID, Kind: r.Kind,
Title: r.Title, Year: r.Year, Reason: reason,
})
}
return rows.Err()
}
// Unstarted = no progress row for this user. Note the parameter order: the
// IN list comes first in the SQL, then the user id inside NOT EXISTS.
base := `
SELECT i.id, i.library_id, i.kind, i.title, COALESCE(i.year, 0)
FROM media_items i
WHERE i.missing = 0 AND i.library_id IN (` + in + `)
AND NOT EXISTS (SELECT 1 FROM progress p WHERE p.item_id = i.id AND p.user_id = ?)`
if hasEngagement {
// 1. Same author.
if e.Author != "" {
rows, err := db.QueryContext(ctx, base+`
AND EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = i.id AND p.name = ?)
ORDER BY i.added_at DESC LIMIT ?`,
withLibs(inArgs, userID, e.Author, limit)...)
if err != nil {
return nil, fmt.Errorf("media: suggestions by author: %w", err)
}
if err := add(rows, "More by "+e.Author); err != nil {
return nil, err
}
}
// 2. Same kind.
rows, err := db.QueryContext(ctx, base+`
AND i.kind = ? ORDER BY i.added_at DESC LIMIT ?`,
withLibs(inArgs, userID, e.Kind, limit)...)
if err != nil {
return nil, fmt.Errorf("media: suggestions by kind: %w", err)
}
if err := add(rows, "More "+kindNoun(e.Kind)); err != nil {
return nil, err
}
}
// 3. Anything recent, as a floor so the shelf is never empty.
rows, err := db.QueryContext(ctx, base+`
ORDER BY i.added_at DESC LIMIT ?`,
withLibs(inArgs, userID, limit)...)
if err != nil {
return nil, fmt.Errorf("media: suggestions fallback: %w", err)
}
if err := add(rows, "Recently added"); err != nil {
return nil, err
}
return out, nil
}
// Explore returns a mixed sample across every kind the user can see.
//
// Excludes anything already on the Continue shelf so the zone feels like
// discovery rather than a repeat, and samples per kind so one large library
// cannot drown out the rest.
func Explore(ctx context.Context, db *sql.DB, userID int64, libraryIDs []int64, limit int) ([]shelfItem, error) {
if len(libraryIDs) == 0 {
return []shelfItem{}, nil
}
if limit <= 0 || limit > 100 {
limit = 30
}
in, inArgs := inClause(libraryIDs)
args := withLibs(inArgs, userID, limit)
query := `
SELECT i.id, i.library_id, i.kind, i.title, 0, i.added_at
FROM media_items i
WHERE i.missing = 0 AND i.library_id IN (` + in + `)
AND NOT EXISTS (
SELECT 1 FROM progress p
WHERE p.item_id = i.id AND p.user_id = ? AND p.finished = 0
)
ORDER BY (i.id * 2654435761) % 1000, i.id
LIMIT ?`
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("media: explore: %w", err)
}
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.UpdatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// withLibs builds a query argument list: the IN-list args first (they appear
// first in these queries), then whatever follows.
//
// Written explicitly because the parameter order in the SQL is not the order
// the caller naturally thinks in, and getting it wrong silently swaps a user id
// for a library id.
func withLibs(inArgs []any, rest ...any) []any {
out := make([]any, 0, len(inArgs)+len(rest))
out = append(out, inArgs...)
out = append(out, rest...)
return out
}
// kindNoun turns a media kind into something readable in a sentence.
func kindNoun(kind string) string {
switch kind {
case "ebooks":
return "books"
case "comics":
return "comics"
case "audiobooks":
return "audiobooks"
case "music":
return "music"
case "photos":
return "photos"
case "games":
return "games"
default:
return strings.ReplaceAll(kind, "-", " ")
}
}

View file

@ -0,0 +1,271 @@
package media
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
type fixture struct {
db *sql.DB
libA int64
libB int64
userA int64
userB int64
itemA int64 // in libA
itemA2 int64 // in libA, unstarted
itemB int64 // in libB
}
func setup(t *testing.T) fixture {
t.Helper()
db := testDB(t)
ctx := context.Background()
f := fixture{db: db}
mk := func(q string, args ...any) int64 {
var id int64
if err := db.QueryRowContext(ctx, q, args...).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
f.libA = mk(`INSERT INTO libraries (name, kind, root_path) VALUES ('A','ebooks','/a') RETURNING id`)
f.libB = mk(`INSERT INTO libraries (name, kind, root_path) VALUES ('B','ebooks','/b') RETURNING id`)
f.userA = mk(`INSERT INTO users (username, password_hash) VALUES ('a','x') RETURNING id`)
f.userB = mk(`INSERT INTO users (username, password_hash) VALUES ('b','x') RETURNING id`)
f.itemA = mk(`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'ebooks','a1.epub','A One') RETURNING id`, f.libA)
f.itemA2 = mk(`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'ebooks','a2.epub','A Two') RETURNING id`, f.libA)
f.itemB = mk(`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'ebooks','b1.epub','B One') RETURNING id`, f.libB)
// A file for each item, so progress rows can reference one. media_files.path
// is globally unique, so each gets its own.
for i, it := range []int64{f.itemA, f.itemA2, f.itemB} {
if _, err := db.ExecContext(ctx,
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`, it, "file-"+itoa(int64(i))); err != nil {
t.Fatal(err)
}
}
return f
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func startItem(t *testing.T, f fixture, userID, itemID int64) {
t.Helper()
var fileID int64
if err := f.db.QueryRow(`SELECT id FROM media_files WHERE item_id = ? LIMIT 1`, itemID).Scan(&fileID); err != nil {
t.Fatal(err)
}
if _, err := f.db.Exec(`
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished)
VALUES (?, ?, ?, 'ebooks', 1, 0.5, 0)`, userID, itemID, fileID); err != nil {
t.Fatal(err)
}
}
func ids(items []shelfItem) []int64 {
out := make([]int64, len(items))
for i, it := range items {
out[i] = it.ID
}
return out
}
func contains64(hay []int64, needle int64) bool {
for _, h := range hay {
if h == needle {
return true
}
}
return false
}
func TestMostViewedIsPerUser(t *testing.T) {
f := setup(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
if _, err := RecordView(ctx, f.db, f.userA, f.itemA); err != nil {
t.Fatal(err)
}
}
if _, err := RecordView(ctx, f.db, f.userA, f.itemA2); err != nil {
t.Fatal(err)
}
got, err := MostViewed(ctx, f.db, f.userA, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d, want 2: %v", len(got), ids(got))
}
if got[0].ID != f.itemA || got[0].Views != 3 {
t.Errorf("first = id %d with %d views, want id %d with 3", got[0].ID, got[0].Views, f.itemA)
}
// User B has viewed nothing.
other, err := MostViewed(ctx, f.db, f.userB, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(other) != 0 {
t.Errorf("user B sees %v, want nothing", ids(other))
}
}
// TestScopingRespectsLibraryAndUser guards the parameter order in the
// suggestions and explore queries, where the IN list precedes the user id.
// Getting it wrong silently swaps a user id for a library id.
func TestScopingRespectsLibraryAndUser(t *testing.T) {
f := setup(t)
ctx := context.Background()
// User A started itemA, which lives in libA.
startItem(t, f, f.userA, f.itemA)
// Suggestions: only libA, only unstarted.
sugg, err := Suggestions(ctx, f.db, f.userA, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
for _, s := range sugg {
if s.LibraryID != f.libA {
t.Errorf("suggestion %d is from library %d, want only %d", s.ID, s.LibraryID, f.libA)
}
if s.ID == f.itemA {
t.Error("a started item was suggested")
}
}
if len(sugg) == 0 {
t.Fatal("no suggestions at all")
}
// Explore: same scoping, and it must not include the in-progress item.
explore, err := Explore(ctx, f.db, f.userA, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(explore) == 0 {
t.Fatal("explore returned nothing")
}
for _, e := range explore {
if e.LibraryID != f.libA {
t.Errorf("explore item %d is from library %d, want only %d", e.ID, e.LibraryID, f.libA)
}
if e.ID == f.itemA {
t.Error("explore included an in-progress item")
}
}
// User B, restricted to libB, must not see libA content at all.
suggB, err := Suggestions(ctx, f.db, f.userB, []int64{f.libB}, 10)
if err != nil {
t.Fatal(err)
}
for _, s := range suggB {
if s.LibraryID != f.libB {
t.Errorf("user B suggestion %d leaked from library %d", s.ID, s.LibraryID)
}
}
if contains64(func() []int64 {
out := make([]int64, len(suggB))
for i, s := range suggB {
out[i] = s.ID
}
return out
}(), f.itemA) {
t.Error("user B was suggested an item from a library they cannot see")
}
}
func TestSuggestionsExplainThemselves(t *testing.T) {
f := setup(t)
ctx := context.Background()
// Give user A an author relationship on the started item.
if _, err := f.db.Exec(`
INSERT INTO people (name) VALUES ('Ursula Le Guin')`); err != nil {
t.Fatal(err)
}
var personID int64
if err := f.db.QueryRow(`SELECT id FROM people WHERE name = 'Ursula Le Guin'`).Scan(&personID); err != nil {
t.Fatal(err)
}
if _, err := f.db.Exec(
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`,
f.itemA, personID); err != nil {
t.Fatal(err)
}
// And on the candidate.
if _, err := f.db.Exec(
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`,
f.itemA2, personID); err != nil {
t.Fatal(err)
}
startItem(t, f, f.userA, f.itemA)
sugg, err := Suggestions(ctx, f.db, f.userA, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(sugg) == 0 {
t.Fatal("no suggestions")
}
if sugg[0].ID != f.itemA2 {
t.Errorf("first suggestion = %d, want the same-author item %d", sugg[0].ID, f.itemA2)
}
if sugg[0].Reason != "More by Ursula Le Guin" {
t.Errorf("reason = %q", sugg[0].Reason)
}
}
func TestRecordViewCounts(t *testing.T) {
f := setup(t)
ctx := context.Background()
for i := 1; i <= 4; i++ {
n, err := RecordView(ctx, f.db, f.userA, f.itemA)
if err != nil {
t.Fatal(err)
}
if n != i {
t.Errorf("view %d returned count %d", i, n)
}
}
}

View file

@ -34,6 +34,7 @@ func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome))
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))

View file

@ -4,6 +4,7 @@ import (
"database/sql"
"net/http"
"strings"
"time"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
@ -17,22 +18,36 @@ type shelfItem struct {
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: what to continue, what is new, and how
// much is in each section.
// handleHome powers the landing screen: five shelves plus the counts the
// sidebar needs.
//
// One request rather than three round trips, because the landing screen is the
// 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()
@ -42,62 +57,85 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use
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, homeResponse{
Continue: []shelfItem{},
Recent: []shelfItem{},
CountsByKind: map[string]int{},
})
httpx.WriteJSON(w, http.StatusOK, resp)
return
}
resp.LibraryCount = len(libs)
in, args := inClause(libs)
resp := homeResponse{CountsByKind: map[string]int{}, LibraryCount: len(libs)}
// Continue: started but unfinished, most recent first.
continueQ := `
SELECT i.id, i.library_id, i.kind, i.title, p.percent, p.file_id, p.updated_at
// ── 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 + `)
ORDER BY p.updated_at DESC
LIMIT 20`
rows, err := h.db.QueryContext(ctx, continueQ, append([]any{u.ID}, args...)...)
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
}
resp.Continue, err = scanShelf(rows)
if err != nil {
if resp.Continue, err = scanShelf(rows); err != nil {
h.fail(w, "home: scan continue", err)
return
}
// Recent: newest additions across every visible library.
recentQ := `
SELECT i.id, i.library_id, i.kind, i.title, 0, 0, i.added_at
// ── 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 + `)
WHERE i.missing = 0 AND i.library_id IN (`+in+`)
ORDER BY i.added_at DESC, i.id DESC
LIMIT 40`
rows, err = h.db.QueryContext(ctx, recentQ, args...)
LIMIT 40`, args...)
if err != nil {
h.fail(w, "home: recent", err)
return
}
resp.Recent, err = scanShelf(rows)
if err != nil {
if resp.Recent, err = scanShelf(rows); err != nil {
h.fail(w, "home: scan recent", err)
return
}
// Counts per kind, for the sidebar and section headers.
countQ := `
// ── 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`
rows, err = h.db.QueryContext(ctx, countQ, args...)
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
@ -121,31 +159,31 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use
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
}
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`, it.ID)
authors, err := h.authors(r, it.ID)
if err != nil {
h.fail(w, "authors", err)
return
}
defer rows.Close()
authors := []string{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
h.fail(w, "scan author", err)
return
}
authors = append(authors, name)
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors})
}
@ -154,7 +192,7 @@ func scanShelf(rows *sql.Rows) ([]shelfItem, error) {
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.UpdatedAt); err != nil {
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)
@ -172,3 +210,7 @@ func inClause(ids []int64) (string, []any) {
}
return strings.Join(placeholders, ","), args
}
func nowRFC3339() string {
return time.Now().UTC().Format(time.RFC3339Nano)
}

18
migrations/0006_views.sql Normal file
View file

@ -0,0 +1,18 @@
-- 0006_views.sql — per-user view counts.
--
-- "Most Viewed" is a per-user notion, not a household one: what I re-read is
-- not what you re-read. Counted when an item is actually opened in a reader,
-- not when its detail page is fetched, so the number means "consumed".
--
-- Only the current view/play count and the last time are stored; a full event
-- log would grow without bound for a feature this simple.
CREATE TABLE item_views (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
views INTEGER NOT NULL DEFAULT 0,
last_viewed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (user_id, item_id)
);
CREATE INDEX idx_item_views_user ON item_views(user_id, views DESC);

View file

@ -137,6 +137,9 @@ export const api = {
home: () => request<Home>('GET', '/api/v1/home'),
recordView: (id: number) =>
request<{ item_id: number; views: number }>('POST', `/api/v1/items/${id}/view`),
game: (id: number) =>
request<{
item_id: number
@ -221,12 +224,25 @@ export interface ShelfItem {
title: string
percent: number
file_id: number
views: number
updated_at: string
}
export interface Suggestion {
id: number
library_id: number
kind: string
title: string
year: number
reason: string
}
export interface Home {
continue: ShelfItem[] | null
most_viewed: ShelfItem[] | null
suggestions: Suggestion[] | null
recent: ShelfItem[] | null
explore: ShelfItem[] | null
counts_by_kind: Record<string, number>
library_count: number
total_items: number

View file

@ -8,12 +8,16 @@
wide = false,
size = 'md',
showKind = false,
subtitle = '',
badge = '',
}: {
item: { id: number; title: string; kind?: string }
percent?: number
wide?: boolean
size?: 'sm' | 'md' | 'lg'
showKind?: boolean
subtitle?: string
badge?: string
} = $props()
</script>
@ -26,13 +30,20 @@
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
/>
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
{#if badge}
<span class="badge">{badge}</span>
{/if}
{#if percent > 0}
<span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span>
{/if}
</div>
<span class="meta">
<span class="title">{item.title}</span>
{#if showKind && item.kind}<span class="kind">{item.kind}</span>{/if}
{#if subtitle}
<span class="subtitle">{subtitle}</span>
{:else if showKind && item.kind}
<span class="subtitle">{item.kind}</span>
{/if}
</span>
</button>
@ -76,11 +87,24 @@
-webkit-box-orient: vertical;
line-height: 1.3;
}
.kind {
font-size: 11px;
.subtitle {
font-size: 11.5px;
color: var(--faint);
text-transform: uppercase;
letter-spacing: 0.04em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.66);
color: #fff;
font-size: 11px;
font-weight: 600;
backdrop-filter: blur(4px);
}
.progress {
position: absolute;
@ -97,7 +121,6 @@
height: 100%;
background: var(--accent);
}
/* Sizes only affect the type, not the cover: covers always fill their slot. */
.sm .title {
font-size: 12px;
}

View file

@ -1,9 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError, type Home, type MediaType } from '../api'
import { mediaTypes, user, counts } from '../session'
import { api, ApiError, type Home } from '../api'
import { user, counts } from '../session'
import { go } from '../nav'
import Shelf from './Shelf.svelte'
import Card from './Card.svelte'
let home = $state<Home | null>(null)
let loading = $state(true)
@ -25,41 +26,40 @@
}
const continueItems = $derived(home?.continue ?? [])
const mostViewed = $derived(home?.most_viewed ?? [])
const suggestions = $derived(home?.suggestions ?? [])
const recent = $derived(home?.recent ?? [])
const explore = $derived(home?.explore ?? [])
function percentFor(id: number): number {
return continueItems.find((c) => c.id === id)?.percent ?? 0
}
// Group recent additions by media type so the home screen reads as a set of
// shelves rather than one undifferentiated wall.
function recentOfKind(kind: string) {
return recent.filter((r) => r.kind === kind)
function viewsLabel(item: { id: number }): string {
const n = mostViewed.find((m) => m.id === item.id)?.views ?? 0
return `${n} view${n === 1 ? '' : 's'}`
}
function labelFor(kind: string): string {
return $mediaTypes.find((t: MediaType) => t.Kind === kind)?.Name ?? kind
function reasonFor(item: { id: number; title: string }): string {
return suggestions.find((s) => s.id === item.id)?.reason ?? ''
}
const kindsWithContent = $derived(
($mediaTypes as MediaType[])
.map((t) => t.Kind)
.filter((k) => (home?.counts_by_kind[k] ?? 0) > 0),
)
const greeting = $derived($user?.display_name || $user?.username || '')
const hasAnything = $derived(
continueItems.length + mostViewed.length + suggestions.length + recent.length + explore.length > 0,
)
</script>
{#if loading}
<div class="loading">
<div class="skeleton big"></div>
<div class="skeleton heading"></div>
<div class="skeleton-row">
{#each Array(6) as _}<div class="skeleton tile"></div>{/each}
</div>
</div>
{:else if error}
<div class="alert">{error}</div>
{:else if (home?.total_items ?? 0) === 0}
{:else if !hasAnything}
<div class="empty">
<h3>Nothing in your library yet</h3>
<p>
@ -76,20 +76,32 @@
{:else}
<p class="hello muted">Welcome back, {greeting}.</p>
{#if continueItems.length > 0}
<Shelf title="Continue" items={continueItems} {percentFor} />
{/if}
<Shelf title="Continue" items={continueItems} {percentFor} />
{#each kindsWithContent as kind (kind)}
{@const items = recentOfKind(kind)}
{#if items.length > 0}
<Shelf
title={labelFor(kind)}
{items}
onSeeAll={() => go({ name: 'browse', kind })}
/>
{/if}
{/each}
<Shelf
title="Most viewed"
items={mostViewed}
subtitleFor={viewsLabel}
onSeeAll={() => go({ name: 'browse' })}
/>
<Shelf title="Suggestions" items={suggestions} subtitleFor={reasonFor} />
<Shelf title="Recently added" items={recent} onSeeAll={() => go({ name: 'browse' })} />
{#if explore.length > 0}
<section class="explore">
<header>
<h2>Explore</h2>
<span class="muted small">A mix of everything in your library</span>
</header>
<div class="grid">
{#each explore as item (item.id)}
<Card {item} showKind />
{/each}
</div>
</section>
{/if}
{/if}
<style>
@ -97,12 +109,34 @@
margin: 0 0 var(--space-5);
font-size: 15px;
}
.explore {
margin-top: var(--space-2);
padding-top: var(--space-5);
border-top: 1px solid var(--border);
}
.explore header {
display: flex;
align-items: baseline;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.explore h2 {
font-size: 17px;
}
.small {
font-size: 12.5px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: var(--space-5) var(--space-4);
}
.loading {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.skeleton.big {
.skeleton.heading {
height: 22px;
width: 220px;
border-radius: 6px;
@ -117,4 +151,10 @@
.skeleton.tile {
aspect-ratio: 2 / 3;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: var(--space-4) var(--space-3);
}
}
</style>

View file

@ -23,6 +23,11 @@
let saveTimer: number | undefined
onMount(async () => {
// Count this as a view: the user is actually consuming the item, which is
// what "most viewed" should mean. Best-effort; a failure must not block
// opening the reader.
void api.recordView(itemId).catch(() => {})
try {
const detail = await api.item(itemId)
item = detail.item

View file

@ -5,11 +5,15 @@
title,
items,
percentFor = () => 0,
subtitleFor = () => '',
badgeFor = () => '',
onSeeAll = null,
}: {
title: string
items: { id: number; title: string; kind?: string }[]
percentFor?: (id: number) => number
subtitleFor?: (item: { id: number; title: string; kind?: string }) => string
badgeFor?: (item: { id: number; title: string; kind?: string }) => string
onSeeAll?: (() => void) | null
} = $props()
</script>
@ -25,7 +29,12 @@
<div class="row">
{#each items as item (item.id)}
<div class="slot">
<Card {item} percent={percentFor(item.id)} />
<Card
{item}
percent={percentFor(item.id)}
subtitle={subtitleFor(item)}
badge={badgeFor(item)}
/>
</div>
{/each}
</div>

View file

@ -15,11 +15,15 @@ export const bootError = writable<string | null>(null)
export const counts = writable<Record<string, number>>({})
export async function bootstrap() {
// Media types are public and static, so fetch them independently of auth.
// Coupling them to /auth/me meant a cold, logged-out load rejected the whole
// Promise.all on the expected 401 and left the sidebar with no sections.
await loadMediaTypes()
try {
const [me, types] = await Promise.all([api.me(), api.mediaTypes()])
const me = await api.me()
user.set(me.user)
visibleLibraries.set(me.visible_libraries)
mediaTypes.set(types.media_types)
await refreshLibraries()
} catch (err) {
if (!(err instanceof ApiError && err.status === 401)) {
@ -31,6 +35,15 @@ export async function bootstrap() {
}
}
async function loadMediaTypes() {
try {
const types = await api.mediaTypes()
mediaTypes.set(types.media_types)
} catch {
// Non-fatal: the sidebar simply stays empty until the next load.
}
}
export async function refreshLibraries() {
loadingLibraries.set(true)
try {
@ -45,6 +58,9 @@ export async function login(username: string, password: string) {
const res = await api.login(username, password)
user.set(res.user)
bootError.set(null)
// Belt and braces: if the initial bootstrap failed before login, make sure
// the sidebar still gets its sections.
await loadMediaTypes()
const me = await api.me()
visibleLibraries.set(me.visible_libraries)
await refreshLibraries()