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/home", h.auth.Require(h.handleHome))
|
||||||
mux.Handle("GET /api/v1/items", h.auth.Require(h.handleListItems))
|
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("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}/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}/file", h.auth.Require(h.handleFile))
|
||||||
mux.Handle("GET /api/v1/items/{id}/files/{fileID}", h.auth.Require(h.handleFileByID))
|
mux.Handle("GET /api/v1/items/{id}/files/{fileID}", h.auth.Require(h.handleFileByID))
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
|
@ -17,22 +18,36 @@ type shelfItem struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Percent float64 `json:"percent"`
|
Percent float64 `json:"percent"`
|
||||||
FileID int64 `json:"file_id"`
|
FileID int64 `json:"file_id"`
|
||||||
|
Views int `json:"views"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
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.
|
// homeResponse is everything the landing screen needs in one request.
|
||||||
type homeResponse struct {
|
type homeResponse struct {
|
||||||
Continue []shelfItem `json:"continue"`
|
Continue []shelfItem `json:"continue"`
|
||||||
|
MostViewed []shelfItem `json:"most_viewed"`
|
||||||
|
Suggestions []suggestion `json:"suggestions"`
|
||||||
Recent []shelfItem `json:"recent"`
|
Recent []shelfItem `json:"recent"`
|
||||||
|
Explore []shelfItem `json:"explore"`
|
||||||
CountsByKind map[string]int `json:"counts_by_kind"`
|
CountsByKind map[string]int `json:"counts_by_kind"`
|
||||||
LibraryCount int `json:"library_count"`
|
LibraryCount int `json:"library_count"`
|
||||||
TotalItems int `json:"total_items"`
|
TotalItems int `json:"total_items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHome powers the landing screen: what to continue, what is new, and how
|
// handleHome powers the landing screen: five shelves plus the counts the
|
||||||
// much is in each section.
|
// 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.
|
// 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) {
|
func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
ctx := r.Context()
|
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)
|
h.fail(w, "visible libraries", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp := homeResponse{
|
||||||
|
Continue: []shelfItem{},
|
||||||
|
MostViewed: []shelfItem{},
|
||||||
|
Suggestions: []suggestion{},
|
||||||
|
Recent: []shelfItem{},
|
||||||
|
Explore: []shelfItem{},
|
||||||
|
CountsByKind: map[string]int{},
|
||||||
|
}
|
||||||
if len(libs) == 0 {
|
if len(libs) == 0 {
|
||||||
httpx.WriteJSON(w, http.StatusOK, homeResponse{
|
httpx.WriteJSON(w, http.StatusOK, resp)
|
||||||
Continue: []shelfItem{},
|
|
||||||
Recent: []shelfItem{},
|
|
||||||
CountsByKind: map[string]int{},
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resp.LibraryCount = len(libs)
|
||||||
in, args := inClause(libs)
|
in, args := inClause(libs)
|
||||||
|
|
||||||
resp := homeResponse{CountsByKind: map[string]int{}, LibraryCount: len(libs)}
|
// ── Continue: started but unfinished, one row per item ───────────────
|
||||||
|
//
|
||||||
// Continue: started but unfinished, most recent first.
|
// GROUP BY item_id with MAX(updated_at): SQLite takes the bare column
|
||||||
continueQ := `
|
// values from the row that produced the max, so a multi-track audiobook
|
||||||
SELECT i.id, i.library_id, i.kind, i.title, p.percent, p.file_id, p.updated_at
|
// 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
|
FROM progress p
|
||||||
JOIN media_items i ON i.id = p.item_id
|
JOIN media_items i ON i.id = p.item_id
|
||||||
WHERE p.user_id = ? AND p.finished = 0 AND i.missing = 0
|
WHERE p.user_id = ? AND p.finished = 0 AND i.missing = 0
|
||||||
AND i.library_id IN (` + in + `)
|
AND i.library_id IN (`+in+`)
|
||||||
ORDER BY p.updated_at DESC
|
GROUP BY p.item_id
|
||||||
LIMIT 20`
|
ORDER BY updated_at DESC
|
||||||
rows, err := h.db.QueryContext(ctx, continueQ, append([]any{u.ID}, args...)...)
|
LIMIT 20`, append([]any{u.ID}, args...)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.fail(w, "home: continue", err)
|
h.fail(w, "home: continue", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp.Continue, err = scanShelf(rows)
|
if resp.Continue, err = scanShelf(rows); err != nil {
|
||||||
if err != nil {
|
|
||||||
h.fail(w, "home: scan continue", err)
|
h.fail(w, "home: scan continue", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent: newest additions across every visible library.
|
// ── Most viewed: what this user actually opens ───────────────────────
|
||||||
recentQ := `
|
if resp.MostViewed, err = MostViewed(ctx, h.db, u.ID, libs, 20); err != nil {
|
||||||
SELECT i.id, i.library_id, i.kind, i.title, 0, 0, i.added_at
|
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
|
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
|
ORDER BY i.added_at DESC, i.id DESC
|
||||||
LIMIT 40`
|
LIMIT 40`, args...)
|
||||||
rows, err = h.db.QueryContext(ctx, recentQ, args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.fail(w, "home: recent", err)
|
h.fail(w, "home: recent", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp.Recent, err = scanShelf(rows)
|
if resp.Recent, err = scanShelf(rows); err != nil {
|
||||||
if err != nil {
|
|
||||||
h.fail(w, "home: scan recent", err)
|
h.fail(w, "home: scan recent", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counts per kind, for the sidebar and section headers.
|
// ── Explore: a mixed sample of everything else ───────────────────────
|
||||||
countQ := `
|
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
|
SELECT i.kind, count(*) FROM media_items i
|
||||||
WHERE i.missing = 0 AND i.library_id IN (` + in + `)
|
WHERE i.missing = 0 AND i.library_id IN (`+in+`)
|
||||||
GROUP BY i.kind`
|
GROUP BY i.kind`, args...)
|
||||||
rows, err = h.db.QueryContext(ctx, countQ, args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.fail(w, "home: counts", err)
|
h.fail(w, "home: counts", err)
|
||||||
return
|
return
|
||||||
|
|
@ -121,31 +159,31 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
httpx.WriteJSON(w, http.StatusOK, resp)
|
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.
|
// handleAuthors returns the people credited on an item.
|
||||||
func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
it, ok := h.authorized(w, r, u)
|
it, ok := h.authorized(w, r, u)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := h.db.QueryContext(r.Context(), `
|
authors, err := h.authors(r, it.ID)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.fail(w, "authors", err)
|
h.fail(w, "authors", err)
|
||||||
return
|
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})
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,7 +192,7 @@ func scanShelf(rows *sql.Rows) ([]shelfItem, error) {
|
||||||
out := []shelfItem{}
|
out := []shelfItem{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s shelfItem
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
|
|
@ -172,3 +210,7 @@ func inClause(ids []int64) (string, []any) {
|
||||||
}
|
}
|
||||||
return strings.Join(placeholders, ","), args
|
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'),
|
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) =>
|
game: (id: number) =>
|
||||||
request<{
|
request<{
|
||||||
item_id: number
|
item_id: number
|
||||||
|
|
@ -221,12 +224,25 @@ export interface ShelfItem {
|
||||||
title: string
|
title: string
|
||||||
percent: number
|
percent: number
|
||||||
file_id: number
|
file_id: number
|
||||||
|
views: number
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Suggestion {
|
||||||
|
id: number
|
||||||
|
library_id: number
|
||||||
|
kind: string
|
||||||
|
title: string
|
||||||
|
year: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Home {
|
export interface Home {
|
||||||
continue: ShelfItem[] | null
|
continue: ShelfItem[] | null
|
||||||
|
most_viewed: ShelfItem[] | null
|
||||||
|
suggestions: Suggestion[] | null
|
||||||
recent: ShelfItem[] | null
|
recent: ShelfItem[] | null
|
||||||
|
explore: ShelfItem[] | null
|
||||||
counts_by_kind: Record<string, number>
|
counts_by_kind: Record<string, number>
|
||||||
library_count: number
|
library_count: number
|
||||||
total_items: number
|
total_items: number
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@
|
||||||
wide = false,
|
wide = false,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
showKind = false,
|
showKind = false,
|
||||||
|
subtitle = '',
|
||||||
|
badge = '',
|
||||||
}: {
|
}: {
|
||||||
item: { id: number; title: string; kind?: string }
|
item: { id: number; title: string; kind?: string }
|
||||||
percent?: number
|
percent?: number
|
||||||
wide?: boolean
|
wide?: boolean
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
showKind?: boolean
|
showKind?: boolean
|
||||||
|
subtitle?: string
|
||||||
|
badge?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -26,13 +30,20 @@
|
||||||
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
|
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
|
||||||
/>
|
/>
|
||||||
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
|
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
|
{#if badge}
|
||||||
|
<span class="badge">{badge}</span>
|
||||||
|
{/if}
|
||||||
{#if percent > 0}
|
{#if percent > 0}
|
||||||
<span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span>
|
<span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
<span class="title">{item.title}</span>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -76,11 +87,24 @@
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.kind {
|
.subtitle {
|
||||||
font-size: 11px;
|
font-size: 11.5px;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
text-transform: uppercase;
|
overflow: hidden;
|
||||||
letter-spacing: 0.04em;
|
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 {
|
.progress {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -97,7 +121,6 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
/* Sizes only affect the type, not the cover: covers always fill their slot. */
|
|
||||||
.sm .title {
|
.sm .title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api, ApiError, type Home, type MediaType } from '../api'
|
import { api, ApiError, type Home } from '../api'
|
||||||
import { mediaTypes, user, counts } from '../session'
|
import { user, counts } from '../session'
|
||||||
import { go } from '../nav'
|
import { go } from '../nav'
|
||||||
import Shelf from './Shelf.svelte'
|
import Shelf from './Shelf.svelte'
|
||||||
|
import Card from './Card.svelte'
|
||||||
|
|
||||||
let home = $state<Home | null>(null)
|
let home = $state<Home | null>(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
@ -25,41 +26,40 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const continueItems = $derived(home?.continue ?? [])
|
const continueItems = $derived(home?.continue ?? [])
|
||||||
|
const mostViewed = $derived(home?.most_viewed ?? [])
|
||||||
|
const suggestions = $derived(home?.suggestions ?? [])
|
||||||
const recent = $derived(home?.recent ?? [])
|
const recent = $derived(home?.recent ?? [])
|
||||||
|
const explore = $derived(home?.explore ?? [])
|
||||||
|
|
||||||
function percentFor(id: number): number {
|
function percentFor(id: number): number {
|
||||||
return continueItems.find((c) => c.id === id)?.percent ?? 0
|
return continueItems.find((c) => c.id === id)?.percent ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group recent additions by media type so the home screen reads as a set of
|
function viewsLabel(item: { id: number }): string {
|
||||||
// shelves rather than one undifferentiated wall.
|
const n = mostViewed.find((m) => m.id === item.id)?.views ?? 0
|
||||||
function recentOfKind(kind: string) {
|
return `${n} view${n === 1 ? '' : 's'}`
|
||||||
return recent.filter((r) => r.kind === kind)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelFor(kind: string): string {
|
function reasonFor(item: { id: number; title: string }): string {
|
||||||
return $mediaTypes.find((t: MediaType) => t.Kind === kind)?.Name ?? kind
|
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 greeting = $derived($user?.display_name || $user?.username || '')
|
||||||
|
const hasAnything = $derived(
|
||||||
|
continueItems.length + mostViewed.length + suggestions.length + recent.length + explore.length > 0,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
<div class="skeleton big"></div>
|
<div class="skeleton heading"></div>
|
||||||
<div class="skeleton-row">
|
<div class="skeleton-row">
|
||||||
{#each Array(6) as _}<div class="skeleton tile"></div>{/each}
|
{#each Array(6) as _}<div class="skeleton tile"></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="alert">{error}</div>
|
<div class="alert">{error}</div>
|
||||||
{:else if (home?.total_items ?? 0) === 0}
|
{:else if !hasAnything}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h3>Nothing in your library yet</h3>
|
<h3>Nothing in your library yet</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -76,20 +76,32 @@
|
||||||
{:else}
|
{:else}
|
||||||
<p class="hello muted">Welcome back, {greeting}.</p>
|
<p class="hello muted">Welcome back, {greeting}.</p>
|
||||||
|
|
||||||
{#if continueItems.length > 0}
|
<Shelf title="Continue" items={continueItems} {percentFor} />
|
||||||
<Shelf title="Continue" items={continueItems} {percentFor} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each kindsWithContent as kind (kind)}
|
<Shelf
|
||||||
{@const items = recentOfKind(kind)}
|
title="Most viewed"
|
||||||
{#if items.length > 0}
|
items={mostViewed}
|
||||||
<Shelf
|
subtitleFor={viewsLabel}
|
||||||
title={labelFor(kind)}
|
onSeeAll={() => go({ name: 'browse' })}
|
||||||
{items}
|
/>
|
||||||
onSeeAll={() => go({ name: 'browse', kind })}
|
|
||||||
/>
|
<Shelf title="Suggestions" items={suggestions} subtitleFor={reasonFor} />
|
||||||
{/if}
|
|
||||||
{/each}
|
<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}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -97,12 +109,34 @@
|
||||||
margin: 0 0 var(--space-5);
|
margin: 0 0 var(--space-5);
|
||||||
font-size: 15px;
|
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 {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
.skeleton.big {
|
.skeleton.heading {
|
||||||
height: 22px;
|
height: 22px;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
@ -117,4 +151,10 @@
|
||||||
.skeleton.tile {
|
.skeleton.tile {
|
||||||
aspect-ratio: 2 / 3;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@
|
||||||
let saveTimer: number | undefined
|
let saveTimer: number | undefined
|
||||||
|
|
||||||
onMount(async () => {
|
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 {
|
try {
|
||||||
const detail = await api.item(itemId)
|
const detail = await api.item(itemId)
|
||||||
item = detail.item
|
item = detail.item
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@
|
||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
percentFor = () => 0,
|
percentFor = () => 0,
|
||||||
|
subtitleFor = () => '',
|
||||||
|
badgeFor = () => '',
|
||||||
onSeeAll = null,
|
onSeeAll = null,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
items: { id: number; title: string; kind?: string }[]
|
items: { id: number; title: string; kind?: string }[]
|
||||||
percentFor?: (id: number) => number
|
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
|
onSeeAll?: (() => void) | null
|
||||||
} = $props()
|
} = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -25,7 +29,12 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{#each items as item (item.id)}
|
{#each items as item (item.id)}
|
||||||
<div class="slot">
|
<div class="slot">
|
||||||
<Card {item} percent={percentFor(item.id)} />
|
<Card
|
||||||
|
{item}
|
||||||
|
percent={percentFor(item.id)}
|
||||||
|
subtitle={subtitleFor(item)}
|
||||||
|
badge={badgeFor(item)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,15 @@ export const bootError = writable<string | null>(null)
|
||||||
export const counts = writable<Record<string, number>>({})
|
export const counts = writable<Record<string, number>>({})
|
||||||
|
|
||||||
export async function bootstrap() {
|
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 {
|
try {
|
||||||
const [me, types] = await Promise.all([api.me(), api.mediaTypes()])
|
const me = await api.me()
|
||||||
user.set(me.user)
|
user.set(me.user)
|
||||||
visibleLibraries.set(me.visible_libraries)
|
visibleLibraries.set(me.visible_libraries)
|
||||||
mediaTypes.set(types.media_types)
|
|
||||||
await refreshLibraries()
|
await refreshLibraries()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof ApiError && err.status === 401)) {
|
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() {
|
export async function refreshLibraries() {
|
||||||
loadingLibraries.set(true)
|
loadingLibraries.set(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -45,6 +58,9 @@ export async function login(username: string, password: string) {
|
||||||
const res = await api.login(username, password)
|
const res = await api.login(username, password)
|
||||||
user.set(res.user)
|
user.set(res.user)
|
||||||
bootError.set(null)
|
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()
|
const me = await api.me()
|
||||||
visibleLibraries.set(me.visible_libraries)
|
visibleLibraries.set(me.visible_libraries)
|
||||||
await refreshLibraries()
|
await refreshLibraries()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue