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.
275 lines
7.9 KiB
Go
275 lines
7.9 KiB
Go
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, "-", " ")
|
|
}
|
|
}
|