Files
memby/server/internal/store/genre_affinity.go
T

140 lines
5.5 KiB
Go
Raw Normal View History

2026-08-20 07:54:03 +12:00
package store
import (
"context"
"fmt"
"strconv"
"strings"
"time"
)
// GenreWeight is one genre label and how much of this viewer's watching it accounts for.
//
// The label is Emby's own spelling, verbatim, because the television is what turns labels
// into the categories it draws — "Science Fiction", "Sci-Fi" and "Sci-Fi & Fantasy" are one
// shelf there and three different rows here. Folding them on this side would mean the
// gateway holding a second copy of a catalogue that is deliberately product design on the
// set, and the two would drift the first time a category gained an alias.
type GenreWeight struct {
Genre string
Score float64
}
// GenreAffinity is everything one reading of a viewer's history came to: the weighted
// genres and how many sessions were behind them.
//
// Sessions is carried because it is the only thing that separates "this household watches
// Westerns" from "somebody put a Western on once" — the television refuses to personalise
// below a floor, and a share of a tiny total is not evidence.
type GenreAffinity struct {
Genres []GenreWeight
Sessions int
}
const (
// genreAffinityWindow is how far back a session still counts for. Long enough that a
// household which watches a few evenings a week has something to say, short enough that
// the crime phase somebody went through two years ago is no longer shaping their rail.
genreAffinityWindow = 180 * 24 * time.Hour
// The recency bands. Recent viewing should count for more, and three flat bands are the
// whole of "lightly weight" — an exponential decay would need a half-life nobody could
// defend and would make the answer move between two readings taken the same evening.
genreAffinityRecentWindow = 30 * 24 * time.Hour
genreAffinityMidWindow = 90 * 24 * time.Hour
// genreAffinityLimit caps the labels returned. A real library has a few dozen distinct
// genre strings and the television folds them into sixteen categories; the tail past
// this cannot change an ordering.
genreAffinityLimit = 60
// genreAffinityEngagement is the least of a title somebody must have reached before it
// says anything about their taste. A session that stopped four minutes in is evidence
// they did *not* want it, and counting those is how a rail comes to lead with the genre
// somebody keeps abandoning.
genreAffinityEngagement = 0.5
)
// TracearrGenreAffinity weighs the genres one viewer actually watches.
//
// Nothing here is stored: Tracearr's sessions are already in Postgres and the imported
// catalogue already holds each title's genres as an indexed array, so this is a join over
// two tables the gateway keeps for other reasons. A per-user genre table would be a copy of
// both, wrong the moment either changed, and would need its own reconciliation to stay
// honest — the same trade watchedMsExpr makes for watch time.
//
// An episode is credited to its *series'* genres, which is what emby_series_id is for: an
// episode row in the catalogue inherits them anyway, and a household that watches one crime
// drama nightly should read as watching crime rather than as watching nothing identifiable.
//
// The identity is matched two ways for the reason attributeWatchTime does it — the username
// is what Tracearr and Emby genuinely share, and the recorded Tracearr id is what the
// recommendation builder actually matched, so a viewer renamed in one system keeps their
// history rather than silently reading as new.
func (s *Store) TracearrGenreAffinity(
ctx context.Context,
tracearrUserID, username string,
now time.Time,
) (GenreAffinity, error) {
id := strings.TrimSpace(tracearrUserID)
name := strings.ToLower(strings.TrimSpace(username))
if id == "" && name == "" {
return GenreAffinity{}, nil
}
rows, err := s.pool.Query(ctx, `
WITH viewed AS (
SELECT coalesce(nullif(emby_series_id, ''), emby_item_id) AS item_id,
CASE
WHEN started_at >= $5 THEN 1.0
WHEN started_at >= $4 THEN 0.6
ELSE 0.3
END AS weight
FROM tracearr_sessions
WHERE started_at >= $3
AND (($1::text <> '' AND tracearr_user_id = $1::text)
OR ($2::text <> '' AND lower(username) = $2::text))
AND (watched OR (
total_duration_ms > 0
AND progress_ms::float8 / total_duration_ms >= $6::float8
))
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
),
joined AS (
SELECT viewed.weight, library_items.genres
FROM viewed
JOIN library_items ON library_items.id = viewed.item_id
WHERE cardinality(library_items.genres) > 0
),
scored AS (
SELECT btrim(label) AS genre, sum(joined.weight)::float8 AS score
FROM joined CROSS JOIN LATERAL unnest(joined.genres) AS label
WHERE btrim(label) <> ''
GROUP BY btrim(label)
)
SELECT scored.genre, scored.score, (SELECT count(*) FROM joined)::int
FROM scored
ORDER BY scored.score DESC, scored.genre
LIMIT `+strconv.Itoa(genreAffinityLimit),
id, name,
now.Add(-genreAffinityWindow),
now.Add(-genreAffinityMidWindow),
now.Add(-genreAffinityRecentWindow),
genreAffinityEngagement,
)
if err != nil {
return GenreAffinity{}, fmt.Errorf("store: tracearr genre affinity: %w", err)
}
defer rows.Close()
out := GenreAffinity{Genres: []GenreWeight{}}
for rows.Next() {
var weight GenreWeight
var sessions int
if err := rows.Scan(&weight.Genre, &weight.Score, &sessions); err != nil {
return GenreAffinity{}, fmt.Errorf("store: scan tracearr genre affinity: %w", err)
}
out.Genres = append(out.Genres, weight)
out.Sessions = sessions
}
return out, rows.Err()
}