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