Vellum/internal/home/discover.go
mithral 7ed74825e7 Fix three regressions the video-kind split introduced
All three were found by running the thing rather than reading it, and the
first one is serious.

1. probeable() compared the LIBRARY kind against a hardcoded list. Splitting
   video into movies/tv/adult/hentai left every new kind out of that list, so
   a movies or tv library was never probed and stored no codec facts at all.
   With no facts, resolve cannot tell an unplayable file from a playable one:
   an HEVC file reported "playable via html5" for a browser that cannot decode
   HEVC, and transcoding would never have triggered for anything.

   It now tests the kind the rows carry, through library.ItemKindFor, so every
   video library is probed exactly as the original video kind was and a future
   video kind cannot repeat this.

2. jellyfin CollectionType() had no case for the new kinds, so a Jellyfin
   client saw movies, tv, adult and hentai libraries with no collection type.

3. kindNoun() fell through to its default for the new kinds, which reads "tv"
   in a sentence rather than "shows".

Also hardened the empty-kind fallback in upsertItem to resolve through
ItemKindFor, so it cannot store a library's own name where 'video' belongs.

Verified end-to-end: codec facts are recorded again (v=h264 and v=hevc), an
h264 file resolves to engine html5, and the HEVC file resolves to engine
transcode. That last line is the whole feature working: scan records the
facts, resolve sees the browser cannot decode it, and the server offers to
re-encode it instead of saying the file is unplayable.
2026-10-12 12:10:01 -04:00

395 lines
11 KiB
Go

package home
import (
"context"
"database/sql"
"fmt"
"math/rand"
"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, libraryIDs []int64) (engagement, bool, error) {
var e engagement
// Scoped to libraries the user can still read. Without this, progress from a
// library whose grant was revoked would keep steering the home screen's
// suggestions — confusing on its own, and an information-flow leak about
// content the user no longer has access to.
in, inArgs := inClause(libraryIDs)
args := append([]any{userID}, inArgs...)
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
AND i.library_id IN (`+in+`)
ORDER BY p.updated_at DESC
LIMIT 1`, args...).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, libraryIDs)
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. The shelf is sampled per kind: each kind
// present in the visible libraries gets a fair share of the limit, so one
// large library or format cannot drown out the rest. Within a kind the sample
// starts at a random id and walks an indexed, id-ordered range scan that stops
// once it has enough rows, so repeat visits surface different items without
// scanning or sorting the whole library.
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)
// One small grouped query tells us which kinds are actually present, so we
// can give each of them a fair share of the shelf. Kinds with no visible
// items are ignored.
kindRows, err := db.QueryContext(ctx, `
SELECT kind, count(*)
FROM media_items
WHERE missing = 0 AND library_id IN (`+in+`)
GROUP BY kind`, inArgs...)
if err != nil {
return nil, fmt.Errorf("media: explore: %w", err)
}
kinds := []string{}
for kindRows.Next() {
var kind string
var n int64
if err := kindRows.Scan(&kind, &n); err != nil {
kindRows.Close()
return nil, err
}
if n > 0 {
kinds = append(kinds, kind)
}
}
if err := kindRows.Err(); err != nil {
kindRows.Close()
return nil, err
}
kindRows.Close()
if len(kinds) == 0 {
return []shelfItem{}, nil
}
// Divide the limit across the kinds that exist: one each, then spread the
// remainder over the first kinds. If there are more kinds than slots, the
// extras get nothing on this visit.
base := limit / len(kinds)
rem := limit % len(kinds)
seen := map[int64]bool{}
out := []shelfItem{}
// appendRows runs one bounded, id-ordered range scan for a kind and appends
// any rows not already seen. before selects the wrap-around half of the id
// range (id < start) instead of the forward half (id >= start). It returns
// how many new rows were added.
appendRows := func(kind string, start int64, before bool, n int) (int, error) {
if n <= 0 {
return 0, nil
}
cmp := ">="
if before {
cmp = "<"
}
q := `
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 i.kind = ?
AND i.id ` + cmp + ` ?
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
LIMIT ?`
rows, err := db.QueryContext(ctx, q, withLibs(inArgs, kind, start, userID, n)...)
if err != nil {
return 0, fmt.Errorf("media: explore: %w", err)
}
defer rows.Close()
added := 0
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 added, err
}
if seen[s.ID] {
continue
}
seen[s.ID] = true
out = append(out, s)
added++
}
return added, rows.Err()
}
for i, kind := range kinds {
share := base
if i < rem {
share++
}
if share <= 0 {
continue
}
var maxID int64
if err := db.QueryRowContext(ctx, `
SELECT COALESCE(MAX(id), 0)
FROM media_items
WHERE missing = 0 AND library_id IN (`+in+`) AND kind = ?`,
withLibs(inArgs, kind)...).Scan(&maxID); err != nil {
return nil, fmt.Errorf("media: explore: %w", err)
}
if maxID <= 0 {
continue
}
// A random start keeps repeat visits from showing the same slice.
start := rand.Int63n(maxID) + 1
got, err := appendRows(kind, start, false, share)
if err != nil {
return nil, err
}
if got < share {
// The start landed near the end of the id range; wrap to the
// beginning to fill the kind's share.
if _, err := appendRows(kind, start, true, share-got); err != nil {
return nil, err
}
}
}
// Mix the kinds instead of leaving them in contiguous blocks, then trim.
rand.Shuffle(len(out), func(a, b int) { out[a], out[b] = out[b], out[a] })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// 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"
case "movies", "adult", "hentai", "video":
return "films"
case "tv":
return "shows"
default:
return strings.ReplaceAll(kind, "-", " ")
}
}