0.2.81
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user