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() }