diff --git a/internal/media/discover.go b/internal/media/discover.go new file mode 100644 index 0000000..8ebed2a --- /dev/null +++ b/internal/media/discover.go @@ -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, "-", " ") + } +} diff --git a/internal/media/discover_test.go b/internal/media/discover_test.go new file mode 100644 index 0000000..3fa592a --- /dev/null +++ b/internal/media/discover_test.go @@ -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) + } + } +} diff --git a/internal/media/handlers.go b/internal/media/handlers.go index 905a7dd..da7fbdb 100644 --- a/internal/media/handlers.go +++ b/internal/media/handlers.go @@ -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)) diff --git a/internal/media/home.go b/internal/media/home.go index a2ee98a..15d9d7d 100644 --- a/internal/media/home.go +++ b/internal/media/home.go @@ -4,6 +4,7 @@ import ( "database/sql" "net/http" "strings" + "time" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" @@ -17,22 +18,36 @@ type shelfItem struct { Title string `json:"title"` Percent float64 `json:"percent"` FileID int64 `json:"file_id"` + Views int `json:"views"` UpdatedAt string `json:"updated_at"` } +// suggestion is a recommendation with the reason it was made. +type suggestion struct { + ID int64 `json:"id"` + LibraryID int64 `json:"library_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Year int `json:"year"` + Reason string `json:"reason"` +} + // homeResponse is everything the landing screen needs in one request. type homeResponse struct { Continue []shelfItem `json:"continue"` + MostViewed []shelfItem `json:"most_viewed"` + Suggestions []suggestion `json:"suggestions"` Recent []shelfItem `json:"recent"` + Explore []shelfItem `json:"explore"` CountsByKind map[string]int `json:"counts_by_kind"` LibraryCount int `json:"library_count"` TotalItems int `json:"total_items"` } -// handleHome powers the landing screen: what to continue, what is new, and how -// much is in each section. +// handleHome powers the landing screen: five shelves plus the counts the +// sidebar needs. // -// One request rather than three round trips, because the landing screen is the +// One request rather than six round trips, because the landing screen is the // first thing a user sees and should not pop in piece by piece. func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.User) { ctx := r.Context() @@ -42,62 +57,85 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use h.fail(w, "visible libraries", err) return } + + resp := homeResponse{ + Continue: []shelfItem{}, + MostViewed: []shelfItem{}, + Suggestions: []suggestion{}, + Recent: []shelfItem{}, + Explore: []shelfItem{}, + CountsByKind: map[string]int{}, + } if len(libs) == 0 { - httpx.WriteJSON(w, http.StatusOK, homeResponse{ - Continue: []shelfItem{}, - Recent: []shelfItem{}, - CountsByKind: map[string]int{}, - }) + httpx.WriteJSON(w, http.StatusOK, resp) return } + resp.LibraryCount = len(libs) in, args := inClause(libs) - resp := homeResponse{CountsByKind: map[string]int{}, LibraryCount: len(libs)} - - // Continue: started but unfinished, most recent first. - continueQ := ` - SELECT i.id, i.library_id, i.kind, i.title, p.percent, p.file_id, p.updated_at + // ── Continue: started but unfinished, one row per item ─────────────── + // + // GROUP BY item_id with MAX(updated_at): SQLite takes the bare column + // values from the row that produced the max, so a multi-track audiobook + // contributes its most recent track rather than one card per chapter. + rows, err := h.db.QueryContext(ctx, ` + SELECT i.id, i.library_id, i.kind, i.title, p.percent, p.file_id, 0, + MAX(p.updated_at) AS updated_at FROM progress p JOIN media_items i ON i.id = p.item_id WHERE p.user_id = ? AND p.finished = 0 AND i.missing = 0 - AND i.library_id IN (` + in + `) - ORDER BY p.updated_at DESC - LIMIT 20` - rows, err := h.db.QueryContext(ctx, continueQ, append([]any{u.ID}, args...)...) + AND i.library_id IN (`+in+`) + GROUP BY p.item_id + ORDER BY updated_at DESC + LIMIT 20`, append([]any{u.ID}, args...)...) if err != nil { h.fail(w, "home: continue", err) return } - resp.Continue, err = scanShelf(rows) - if err != nil { + if resp.Continue, err = scanShelf(rows); err != nil { h.fail(w, "home: scan continue", err) return } - // Recent: newest additions across every visible library. - recentQ := ` - SELECT i.id, i.library_id, i.kind, i.title, 0, 0, i.added_at + // ── Most viewed: what this user actually opens ─────────────────────── + if resp.MostViewed, err = MostViewed(ctx, h.db, u.ID, libs, 20); err != nil { + h.fail(w, "home: most viewed", err) + return + } + + // ── Suggestions: related to what they have been consuming ──────────── + if resp.Suggestions, err = Suggestions(ctx, h.db, u.ID, libs, 20); err != nil { + h.fail(w, "home: suggestions", err) + return + } + + // ── Recently added ─────────────────────────────────────────────────── + rows, err = h.db.QueryContext(ctx, ` + SELECT i.id, i.library_id, i.kind, i.title, 0, 0, 0, i.added_at FROM media_items i - WHERE i.missing = 0 AND i.library_id IN (` + in + `) + WHERE i.missing = 0 AND i.library_id IN (`+in+`) ORDER BY i.added_at DESC, i.id DESC - LIMIT 40` - rows, err = h.db.QueryContext(ctx, recentQ, args...) + LIMIT 40`, args...) if err != nil { h.fail(w, "home: recent", err) return } - resp.Recent, err = scanShelf(rows) - if err != nil { + if resp.Recent, err = scanShelf(rows); err != nil { h.fail(w, "home: scan recent", err) return } - // Counts per kind, for the sidebar and section headers. - countQ := ` + // ── Explore: a mixed sample of everything else ─────────────────────── + if resp.Explore, err = Explore(ctx, h.db, u.ID, libs, 30); err != nil { + h.fail(w, "home: explore", err) + return + } + + // ── Counts per kind, for the sidebar and section headers ───────────── + rows, err = h.db.QueryContext(ctx, ` SELECT i.kind, count(*) FROM media_items i - WHERE i.missing = 0 AND i.library_id IN (` + in + `) - GROUP BY i.kind` - rows, err = h.db.QueryContext(ctx, countQ, args...) + WHERE i.missing = 0 AND i.library_id IN (`+in+`) + GROUP BY i.kind`, args...) if err != nil { h.fail(w, "home: counts", err) return @@ -121,31 +159,31 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use httpx.WriteJSON(w, http.StatusOK, resp) } +// handleRecordView increments the user's view count for an item. +func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + views, err := RecordView(r.Context(), h.db, u.ID, it.ID) + if err != nil { + h.fail(w, "record view", err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"item_id": it.ID, "views": views}) +} + // handleAuthors returns the people credited on an item. func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) { it, ok := h.authorized(w, r, u) if !ok { return } - rows, err := h.db.QueryContext(r.Context(), ` - SELECT p.name FROM item_people ip - JOIN people p ON p.id = ip.person_id - WHERE ip.item_id = ? ORDER BY ip.ord`, it.ID) + authors, err := h.authors(r, it.ID) if err != nil { h.fail(w, "authors", err) return } - defer rows.Close() - - authors := []string{} - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - h.fail(w, "scan author", err) - return - } - authors = append(authors, name) - } httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors}) } @@ -154,7 +192,7 @@ func scanShelf(rows *sql.Rows) ([]shelfItem, error) { out := []shelfItem{} for rows.Next() { var s shelfItem - if err := rows.Scan(&s.ID, &s.LibraryID, &s.Kind, &s.Title, &s.Percent, &s.FileID, &s.UpdatedAt); err != nil { + if err := rows.Scan(&s.ID, &s.LibraryID, &s.Kind, &s.Title, &s.Percent, &s.FileID, &s.Views, &s.UpdatedAt); err != nil { return nil, err } out = append(out, s) @@ -172,3 +210,7 @@ func inClause(ids []int64) (string, []any) { } return strings.Join(placeholders, ","), args } + +func nowRFC3339() string { + return time.Now().UTC().Format(time.RFC3339Nano) +} diff --git a/migrations/0006_views.sql b/migrations/0006_views.sql new file mode 100644 index 0000000..feb3bd6 --- /dev/null +++ b/migrations/0006_views.sql @@ -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); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 92a57e6..89448b3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -137,6 +137,9 @@ export const api = { home: () => request('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 library_count: number total_items: number diff --git a/web/src/lib/components/Card.svelte b/web/src/lib/components/Card.svelte index 7b8026d..4cd19f7 100644 --- a/web/src/lib/components/Card.svelte +++ b/web/src/lib/components/Card.svelte @@ -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() @@ -26,13 +30,20 @@ onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} /> {(item.title || '?').slice(0, 1).toUpperCase()} + {#if badge} + {badge} + {/if} {#if percent > 0} {/if} {item.title} - {#if showKind && item.kind}{item.kind}{/if} + {#if subtitle} + {subtitle} + {:else if showKind && item.kind} + {item.kind} + {/if} @@ -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; } diff --git a/web/src/lib/components/Home.svelte b/web/src/lib/components/Home.svelte index a921d1d..c68b23c 100644 --- a/web/src/lib/components/Home.svelte +++ b/web/src/lib/components/Home.svelte @@ -1,9 +1,10 @@ {#if loading}
-
+
{#each Array(6) as _}
{/each}
{:else if error}
{error}
-{:else if (home?.total_items ?? 0) === 0} +{:else if !hasAnything}

Nothing in your library yet

@@ -76,20 +76,32 @@ {:else}

Welcome back, {greeting}.

- {#if continueItems.length > 0} - - {/if} + - {#each kindsWithContent as kind (kind)} - {@const items = recentOfKind(kind)} - {#if items.length > 0} - go({ name: 'browse', kind })} - /> - {/if} - {/each} + go({ name: 'browse' })} + /> + + + + go({ name: 'browse' })} /> + + {#if explore.length > 0} +
+
+

Explore

+ A mix of everything in your library +
+
+ {#each explore as item (item.id)} + + {/each} +
+
+ {/if} {/if} diff --git a/web/src/lib/components/Reader.svelte b/web/src/lib/components/Reader.svelte index 5bcc590..03db064 100644 --- a/web/src/lib/components/Reader.svelte +++ b/web/src/lib/components/Reader.svelte @@ -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 diff --git a/web/src/lib/components/Shelf.svelte b/web/src/lib/components/Shelf.svelte index 390a5c0..4cdb1a8 100644 --- a/web/src/lib/components/Shelf.svelte +++ b/web/src/lib/components/Shelf.svelte @@ -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() @@ -25,7 +29,12 @@
{#each items as item (item.id)}
- +
{/each}
diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts index e3f9b6f..04b5a61 100644 --- a/web/src/lib/session.ts +++ b/web/src/lib/session.ts @@ -15,11 +15,15 @@ export const bootError = writable(null) export const counts = writable>({}) 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()