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:
parent
e21661460a
commit
4c51482dd6
11 changed files with 803 additions and 87 deletions
275
internal/media/discover.go
Normal file
275
internal/media/discover.go
Normal 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, "-", " ")
|
||||
}
|
||||
}
|
||||
271
internal/media/discover_test.go
Normal file
271
internal/media/discover_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
if len(libs) == 0 {
|
||||
httpx.WriteJSON(w, http.StatusOK, homeResponse{
|
||||
|
||||
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)
|
||||
|
||||
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...)...)
|
||||
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+`)
|
||||
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...)
|
||||
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
18
migrations/0006_views.sql
Normal 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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
{#each kindsWithContent as kind (kind)}
|
||||
{@const items = recentOfKind(kind)}
|
||||
{#if items.length > 0}
|
||||
<Shelf
|
||||
title={labelFor(kind)}
|
||||
{items}
|
||||
onSeeAll={() => go({ name: 'browse', kind })}
|
||||
title="Most viewed"
|
||||
items={mostViewed}
|
||||
subtitleFor={viewsLabel}
|
||||
onSeeAll={() => go({ name: 'browse' })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue