Files
silo-server/internal/recommendations/reader.go
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction

Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing
stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with
no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged.

Bootstrap (internal/telemetry):
- Setup() builds one shared resource, a TracerProvider (parent-based trace-id
  ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator
  from env. It installs NO MeterProvider — metrics stay on Prometheus, and the
  built-in no-op global MeterProvider keeps the trace instrumentation libs from
  double-emitting. Shutdown is deferred with a flush timeout.
- Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the
  shared LevelVar and best-effort so a failing collector can't break the console
  or DB branches. stderr + opslog stay untouched.

Secret redaction (internal/logredact):
- A slog.Handler masks secret-keyed attributes (password, token, api_key,
  authorization, cookie, ...) — including .With-bound attrs, nested groups,
  secret-keyed group subtrees, and values behind a LogValuer — on the console
  and OTLP sinks, with a no-op fast path when a record has no secret keys.
  opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one
  marker list.

Rotation is infra-managed (no custom file sink): container runtime for stderr,
collector/backend for OTLP, opslog partition-pruning for the DB. Documented in
docs/architecture/observability.md.

Verification: go build ./..., go vet, gofmt -l — clean; go test
./internal/telemetry/ ./internal/logredact/ -race pass.

AI-use disclosure: implemented with AI assistance (Claude Code), including
adversarial reviews that hardened the bootstrap and fixed two redaction leak
paths; reviewed by the author.

* refactor(observability): slog context+component sweep, sloglint gate (phase 3)

Part of #265. Builds on the OTel bootstrap + redaction commit.

Standardizes every log call site onto the context-carrying slog variants so
records correlate with the active OpenTelemetry trace, and locks the standard
in with a machine gate so future code (human- or AI-authored) can't drift back.

- Call-site sweep: converted the remaining slog.<Level>(...) calls to the
  slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope
  (background/init calls with no ctx are left as-is), across 183 files. Applied
  via a type-aware AST codemod. Log levels and message strings are preserved
  verbatim; a component attr (canonical per-package name) is added to direct
  package-level slog calls. Bound-logger calls keep their existing .With
  bindings. The main.go and telemetry package conversions rode with their file
  in the previous commit to keep each file within a single commit.
- Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg,
  key-naming-case=snake, no-mixed-args. After the sweep all four report zero
  violations repo-wide (tests included), so make lint / CI now blocks any
  regression to the non-context form. The gate ships with the sweep because it
  cannot be green until the legacy sites are converted.

Metrics remain on Prometheus; no behavior change to /metrics or Grafana.

Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4
rules) 0 violations repo-wide; log levels verified unchanged.

AI-use disclosure: implemented with AI assistance (Claude Code), including the
codemod; reviewed by the author.

* fix(observability): honor per-signal OTLP protocol and secret WithGroup names

Two Codex review findings on PR #290:

- telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the
  generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector
  setups (e.g. HTTP logs + gRPC traces) build the right exporter.
- logredact: entering a group whose name is secret-bearing (e.g.
  WithGroup("authorization")) now masks every leaf in that subtree,
  matching how slog.Group("authorization", ...) is masked as a whole.

* fix(observability): address review feedback on telemetry bootstrap

- Telemetry setup failure no longer kills boot: Setup returns usable
  no-op providers alongside the error and main logs and continues with
  telemetry disabled, honoring the best-effort contract.
- Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_*
  variants); unsupported values fall back to parentbased_traceidratio.
- Attach node identity as semconv service.instance.id instead of the
  non-semconv node.name.
- Rename opslog retention-scope log attrs to target_component/target_level
  so they no longer collide with the canonical component routing key, and
  tag those lines with component=opslog.
- Fix stale levelGated comment casing; use WarnContext in the telemetry
  shutdown defer; document the LogValuer double-resolve on the redaction
  slow path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:53:52 -04:00

744 lines
21 KiB
Go

package recommendations
import (
"context"
"fmt"
"log/slog"
"sort"
"strconv"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// SectionKind identifies a single recommendation row reachable via a dedicated
// "see all" page. Stable strings used in URLs and the discover API response.
const (
SectionKindForYouMain = "for-you-main"
SectionKindCluster = "cluster"
SectionKindSimilarUsers = "similar-users"
SectionKindPopular = "popular"
SectionKindRecentlyAdded = "recently-added"
SectionKindTopRated = "top-rated"
SectionKindGenre = "genre"
)
// ProfileRefreshRequester queues a profile-scoped refresh without blocking the caller.
type ProfileRefreshRequester interface {
RequestProfileRefresh(ctx context.Context, userID int, profileID string)
}
// Reader assembles recommendation rows from cache-backed data sources.
type Reader struct {
repo *Repo
ratingsRepo *catalog.RatingsRepo
refresh ProfileRefreshRequester
signals *SignalReader
}
// NewReader creates a cache-backed recommendations reader.
func NewReader(repo *Repo, ratingsRepo *catalog.RatingsRepo, refresh ProfileRefreshRequester, storeProvider userstore.UserStoreProvider) *Reader {
return &Reader{
repo: repo,
ratingsRepo: ratingsRepo,
refresh: refresh,
signals: NewSignalReader(repo, storeProvider),
}
}
func (r *Reader) signalReader() *SignalReader {
if r.signals != nil {
return r.signals
}
return NewSignalReader(r.repo, nil)
}
// GetForYouMain returns the first row the recommendations page should display.
func (r *Reader) GetForYouMain(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) (*ForYouRow, error) {
limit = normalizeRecommendationLimit(limit)
rows, _, err := r.getForYouPageRows(ctx, userID, profileID, filter)
if err != nil {
return nil, err
}
rows = trimRows(rows, limit)
if len(rows) == 0 {
return nil, nil
}
return &rows[0], nil
}
// GetForYouRows returns the remaining recommendations-page rows after the main row.
func (r *Reader) GetForYouRows(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]ForYouRow, error) {
limit = normalizeRecommendationLimit(limit)
rows, _, err := r.getForYouPageRows(ctx, userID, profileID, filter)
if err != nil {
return nil, err
}
rows = trimRows(rows, limit)
if len(rows) <= 1 {
return []ForYouRow{}, nil
}
return rows[1:], nil
}
// GetSimilarUsersLiked returns the cached collaborative row for the profile.
func (r *Reader) GetSimilarUsersLiked(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]ScoredItem, error) {
limit = normalizeRecommendationLimit(limit)
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeSimilarUsersLiked, "")
if err != nil {
return nil, err
}
if len(items) == 0 {
if r.refresh != nil {
r.refresh.RequestProfileRefresh(ctx, userID, profileID)
}
return []ScoredItem{}, nil
}
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{{
Type: "similar_users_liked",
Label: "Fans Like You Also Enjoyed",
Items: items,
}}, filter)
if err != nil {
return nil, err
}
rows = trimRows(rows, limit)
if len(rows) == 0 {
return []ScoredItem{}, nil
}
return rows[0].Items, nil
}
// GetBecauseYouWatched returns a cached because-you-watched row, using the
// requested source item when provided or the most recent completed items when not.
func (r *Reader) GetBecauseYouWatched(ctx context.Context, userID int, profileID, sourceItemID string, limit int, filter catalog.AccessFilter) ([]ScoredItem, error) {
limit = normalizeRecommendationLimit(limit)
sourceIDs := []string{}
if sourceItemID != "" {
sourceIDs = append(sourceIDs, sourceItemID)
} else {
recentCompleted, err := r.signalReader().RecentCompletedItemIDs(ctx, userID, profileID, 3)
if err != nil {
return nil, err
}
sourceIDs = append(sourceIDs, recentCompleted...)
}
for _, sourceID := range sourceIDs {
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeBecauseWatched, sourceID)
if err != nil {
return nil, err
}
if len(items) == 0 {
continue
}
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{{
Type: RecTypeBecauseWatched,
Label: "Because You Watched",
Items: items,
}}, filter)
if err != nil {
return nil, err
}
rows = trimRows(rows, limit)
if len(rows) == 0 {
return []ScoredItem{}, nil
}
return rows[0].Items, nil
}
if r.refresh != nil {
r.refresh.RequestProfileRefresh(ctx, userID, profileID)
}
return []ScoredItem{}, nil
}
// GetTasteMatchRow returns the strongest matching personalized cluster row for a genre,
// falling back to the global genre sampler when no personalized cluster matches.
// An empty genre auto-picks: every taste cluster qualifies (strongest first),
// and the global fallback uses the server-wide top genre.
func (r *Reader) GetTasteMatchRow(ctx context.Context, userID int, profileID, genre string, limit int, filter catalog.AccessFilter) (*ForYouRow, error) {
limit = normalizeRecommendationLimit(limit)
clusters, err := r.repo.GetTasteClusters(ctx, userID, profileID)
if err != nil {
return nil, err
}
matching := make([]TasteCluster, 0, len(clusters))
for _, cluster := range clusters {
if genre == "" {
matching = append(matching, cluster)
continue
}
for _, dominantGenre := range cluster.DominantGenres {
if dominantGenre == genre {
matching = append(matching, cluster)
break
}
}
}
sort.SliceStable(matching, func(i, j int) bool {
return matching[i].TotalWeight > matching[j].TotalWeight
})
for _, cluster := range matching {
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouClusterPrefix+itoa(cluster.ClusterIdx), "")
if err != nil {
return nil, err
}
if len(items) == 0 {
if r.refresh != nil {
r.refresh.RequestProfileRefresh(ctx, userID, profileID)
}
continue
}
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{clusterRow(cluster, items)}, filter)
if err != nil {
return nil, err
}
rows = trimRows(rows, limit)
if len(rows) == 0 {
// This cluster's cached items were entirely filtered out (e.g.
// access restrictions) — try the next-strongest cluster instead of
// giving up before the global fallback below.
continue
}
return &rows[0], nil
}
if genre == "" {
topGenres, err := r.repo.GetTopGenres(ctx, 1)
if err != nil || len(topGenres) == 0 {
return nil, err
}
genre = topGenres[0]
}
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeGenreSamplerPrefix+genre, "")
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, nil
}
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{{
Type: "genre_sampler",
Label: "Top " + genre,
Items: items,
}}, filter)
if err != nil {
return nil, err
}
rows = trimRows(rows, limit)
if len(rows) == 0 {
return nil, nil
}
return &rows[0], nil
}
func (r *Reader) getForYouPageRows(ctx context.Context, userID int, profileID string, filter catalog.AccessFilter) ([]ForYouRow, bool, error) {
level := 0
meta, err := r.repo.GetTasteProfileMeta(ctx, userID, profileID)
if err != nil {
return nil, false, err
}
if meta != nil {
positiveSignals := 0
for key, count := range meta.SignalCounts {
switch key {
case "rated_low", "watch_low":
default:
positiveSignals += count
}
}
level = coldStartLevel(positiveSignals)
}
globalRows, err := r.getGlobalRows(ctx)
if err != nil {
return nil, false, err
}
clusterRows, missingClusters, err := r.getClusterRows(ctx, userID, profileID)
if err != nil {
return nil, false, err
}
personalRows := make([]ForYouRow, 0, 1+len(clusterRows))
mainItems, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouMain, "")
if err != nil {
return nil, false, err
}
missingPersonalized := level > 0 && (len(mainItems) == 0 || missingClusters)
if len(mainItems) > 0 {
personalRows = append(personalRows, ForYouRow{
Type: "cluster",
Label: "For You",
Items: mainItems,
})
}
personalRows = append(personalRows, clusterRows...)
if level == 0 && len(personalRows) > 0 {
level = 3
}
rows := mergePersonalizedAndColdStart(personalRows, globalRows, level)
rows, err = r.filterRows(ctx, userID, profileID, rows, filter)
if err != nil {
return nil, false, err
}
if missingPersonalized && r.refresh != nil {
r.refresh.RequestProfileRefresh(ctx, userID, profileID)
}
return rows, missingPersonalized, nil
}
func (r *Reader) getGlobalRows(ctx context.Context) ([]ForYouRow, error) {
popular, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypePopular, "")
if err != nil {
return nil, err
}
recentlyAdded, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeRecentlyAdded, "")
if err != nil {
return nil, err
}
topRated, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeTopRated, "")
if err != nil {
return nil, err
}
return buildColdStartRows(popular, recentlyAdded, topRated, map[string][]ScoredItem{}), nil
}
func (r *Reader) getClusterRows(ctx context.Context, userID int, profileID string) ([]ForYouRow, bool, error) {
clusters, err := r.repo.GetTasteClusters(ctx, userID, profileID)
if err != nil {
return nil, false, err
}
if len(clusters) == 0 {
return []ForYouRow{}, false, nil
}
rows := make([]ForYouRow, 0, len(clusters))
missing := false
for _, cluster := range clusters {
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouClusterPrefix+itoa(cluster.ClusterIdx), "")
if err != nil {
return nil, false, err
}
if len(items) == 0 {
missing = true
continue
}
rows = append(rows, clusterRow(cluster, items))
}
return rows, missing, nil
}
func clusterRow(cluster TasteCluster, items []ScoredItem) ForYouRow {
label := cluster.Label
if label == "" {
label = "For You"
}
return ForYouRow{
Type: "cluster",
Label: "Because you enjoy " + label,
ClusterIndex: cluster.ClusterIdx,
Items: items,
}
}
func (r *Reader) filterRows(ctx context.Context, userID int, profileID string, rows []ForYouRow, filter catalog.AccessFilter) ([]ForYouRow, error) {
if len(rows) == 0 {
return rows, nil
}
watchedSet, err := r.signalReader().WatchedItemIDSet(ctx, userID, profileID)
if err != nil {
return nil, err
}
itemIDs := make([]string, 0)
for _, row := range rows {
for _, item := range row.Items {
itemIDs = append(itemIDs, item.MediaItemID)
}
}
lowRatings := map[string]int{}
if len(itemIDs) > 0 && r.ratingsRepo != nil {
lowRatings, err = r.ratingsRepo.ListForItems(ctx, userID, profileID, itemIDs)
if err != nil {
return nil, err
}
}
accessible := map[string]struct{}{}
if len(itemIDs) > 0 {
var err error
accessible, err = r.repo.FilterAccessibleItemIDs(ctx, itemIDs, filter)
if err != nil {
return nil, err
}
}
filteredRows := make([]ForYouRow, 0, len(rows))
for _, row := range rows {
filteredItems := make([]ScoredItem, 0, len(row.Items))
for _, item := range row.Items {
if _, ok := accessible[item.MediaItemID]; !ok {
continue
}
if _, watched := watchedSet[item.MediaItemID]; watched {
continue
}
if rating, rated := lowRatings[item.MediaItemID]; rated && rating <= 2 {
continue
}
filteredItems = append(filteredItems, item)
}
if len(filteredItems) == 0 {
continue
}
row.Items = filteredItems
filteredRows = append(filteredRows, row)
}
return filteredRows, nil
}
// GetDiscoverRows assembles all rows for the discover/recommendations page.
// It combines personalized for-you rows, similar-users, and random genre-based
// popular rows. All data is read from cache — no live aggregation queries.
func (r *Reader) GetDiscoverRows(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]ForYouRow, error) {
limit = normalizeRecommendationLimit(limit)
// 1. For-you rows (personalized + cold-start blended, already filtered).
forYouRows, _, err := r.getForYouPageRows(ctx, userID, profileID, filter)
if err != nil {
return nil, err
}
// Track seen items for cross-row deduplication.
seen := make(map[string]struct{})
for i := range forYouRows {
forYouRows[i].Items = deduplicateItems(forYouRows[i].Items, seen)
}
forYouRows = trimRows(forYouRows, limit)
// Drop rows emptied by dedup.
forYouRows = dropEmptyRows(forYouRows)
// 2. Similar users row.
var extraRows []ForYouRow
similarItems, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeSimilarUsersLiked, "")
if err != nil {
return nil, err
}
if len(similarItems) > 0 {
extraRows = append(extraRows, ForYouRow{
Type: "similar_users_liked",
Label: "Users Like You Also Enjoyed",
Items: similarItems,
})
}
// 3. Genre sampler rows — pick random genres from global cache.
genreSamplers, err := r.repo.ListCachedGenreSamplers(ctx)
if err != nil {
return nil, err
}
if len(genreSamplers) > 0 {
// Exclude genres that overlap with the user's taste clusters.
excludeGenres := make(map[string]struct{})
clusters, clusterErr := r.repo.GetTasteClusters(ctx, userID, profileID)
if clusterErr != nil {
slog.WarnContext(ctx, "GetDiscoverRows: failed to load taste clusters for genre exclusion", "component", "recommendations", "user_id", userID, "profile_id", profileID, "error", clusterErr)
}
for _, c := range clusters {
for _, g := range c.DominantGenres {
excludeGenres[g] = struct{}{}
}
}
var availableGenres []string
for genre := range genreSamplers {
if _, excluded := excludeGenres[genre]; !excluded {
availableGenres = append(availableGenres, genre)
}
}
// Deterministic daily shuffle based on profile + date.
selected := selectDailyGenres(availableGenres, profileID, 4)
for _, genre := range selected {
items := genreSamplers[genre]
extraRows = append(extraRows, ForYouRow{
Type: "genre_sampler",
Label: "Popular in " + genre,
Items: items,
})
}
}
// Filter extra rows (watched + low-rated) and deduplicate across all rows.
extraRows, err = r.filterRows(ctx, userID, profileID, extraRows, filter)
if err != nil {
return nil, err
}
for i := range extraRows {
extraRows[i].Items = deduplicateItems(extraRows[i].Items, seen)
}
extraRows = trimRows(extraRows, limit)
extraRows = dropEmptyRows(extraRows)
// Interleave: for-you rows first, then genre rows woven after every 2 for-you rows,
// similar-users at the end.
var result []ForYouRow
var genreRows []ForYouRow
var similarRow *ForYouRow
for i := range extraRows {
if extraRows[i].Type == "similar_users_liked" {
similarRow = &extraRows[i]
} else {
genreRows = append(genreRows, extraRows[i])
}
}
genreIdx := 0
for i, row := range forYouRows {
result = append(result, row)
// Insert a genre row after every 2nd for-you row.
if (i+1)%2 == 0 && genreIdx < len(genreRows) {
result = append(result, genreRows[genreIdx])
genreIdx++
}
}
// Append remaining genre rows.
for ; genreIdx < len(genreRows); genreIdx++ {
result = append(result, genreRows[genreIdx])
}
// Similar users at the end.
if similarRow != nil && len(similarRow.Items) > 0 {
result = append(result, *similarRow)
}
return result, nil
}
// GetSection returns a single recommendation row identified by kind/key, used
// by dedicated "see all" pages. Returns nil if the row is unknown or empty
// after filtering. The label and type mirror what the discover/for-you
// endpoints would produce so consumers can render a consistent header.
func (r *Reader) GetSection(
ctx context.Context,
userID int,
profileID, kind, key string,
limit int,
filter catalog.AccessFilter,
) (*ForYouRow, error) {
if limit <= 0 || limit > CacheCandidateLimit {
limit = CacheCandidateLimit
}
row, err := r.loadSectionRow(ctx, userID, profileID, kind, key)
if err != nil || row == nil {
return nil, err
}
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{*row}, filter)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
filtered := rows[0]
if len(filtered.Items) > limit {
filtered.Items = filtered.Items[:limit]
}
return &filtered, nil
}
func (r *Reader) loadSectionRow(ctx context.Context, userID int, profileID, kind, key string) (*ForYouRow, error) {
switch kind {
case SectionKindForYouMain:
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouMain, "")
if err != nil || len(items) == 0 {
return nil, err
}
return &ForYouRow{Type: "cluster", Label: "For You", Items: items}, nil
case SectionKindCluster:
idx, err := strconv.Atoi(key)
if err != nil {
return nil, fmt.Errorf("invalid cluster index %q: %w", key, err)
}
clusters, err := r.repo.GetTasteClusters(ctx, userID, profileID)
if err != nil {
return nil, err
}
var match *TasteCluster
for i := range clusters {
if clusters[i].ClusterIdx == idx {
match = &clusters[i]
break
}
}
if match == nil {
return nil, nil
}
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouClusterPrefix+itoa(idx), "")
if err != nil || len(items) == 0 {
return nil, err
}
row := clusterRow(*match, items)
return &row, nil
case SectionKindSimilarUsers:
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeSimilarUsersLiked, "")
if err != nil || len(items) == 0 {
return nil, err
}
return &ForYouRow{
Type: "similar_users_liked",
Label: "Users Like You Also Enjoyed",
Items: items,
}, nil
case SectionKindPopular:
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypePopular, "")
if err != nil || len(items) == 0 {
return nil, err
}
return &ForYouRow{Type: RecTypePopular, Label: "Popular on This Server", Items: items}, nil
case SectionKindRecentlyAdded:
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeRecentlyAdded, "")
if err != nil || len(items) == 0 {
return nil, err
}
return &ForYouRow{Type: RecTypeRecentlyAdded, Label: "Recently Added", Items: items}, nil
case SectionKindTopRated:
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeTopRated, "")
if err != nil || len(items) == 0 {
return nil, err
}
return &ForYouRow{Type: RecTypeTopRated, Label: "Top Rated", Items: items}, nil
case SectionKindGenre:
if key == "" {
return nil, fmt.Errorf("genre section requires a key")
}
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeGenreSamplerPrefix+key, "")
if err != nil || len(items) == 0 {
return nil, err
}
return &ForYouRow{Type: "genre_sampler", Label: "Popular in " + key, Items: items}, nil
}
return nil, nil
}
// selectDailyGenres picks up to n genres from the available list using a
// deterministic daily seed so the selection is stable within a day for a given profile.
func selectDailyGenres(genres []string, profileID string, n int) []string {
if len(genres) == 0 {
return nil
}
if n > len(genres) {
n = len(genres)
}
// Copy to avoid mutating the caller's slice.
shuffled := make([]string, len(genres))
copy(shuffled, genres)
genres = shuffled
// Sort for determinism before shuffling.
sort.Strings(genres)
// Simple daily seed from profile ID + date.
now := time.Now().UTC()
seed := int64(now.Year())*10000 + int64(now.Month())*100 + int64(now.Day())
for _, b := range profileID {
seed = seed*31 + int64(b)
}
// Fisher-Yates shuffle with deterministic seed.
for i := len(genres) - 1; i > 0; i-- {
seed = (seed*1103515245 + 12345) & 0x7fffffff
j := int(seed) % (i + 1)
genres[i], genres[j] = genres[j], genres[i]
}
return genres[:n]
}
// deduplicateItems removes items whose MediaItemID is already in the seen set,
// and adds surviving items to the set.
func deduplicateItems(items []ScoredItem, seen map[string]struct{}) []ScoredItem {
result := make([]ScoredItem, 0, len(items))
for _, item := range items {
if _, ok := seen[item.MediaItemID]; ok {
continue
}
seen[item.MediaItemID] = struct{}{}
result = append(result, item)
}
return result
}
// dropEmptyRows removes rows with no items.
func dropEmptyRows(rows []ForYouRow) []ForYouRow {
result := make([]ForYouRow, 0, len(rows))
for _, row := range rows {
if len(row.Items) > 0 {
result = append(result, row)
}
}
return result
}
func trimRows(rows []ForYouRow, limit int) []ForYouRow {
if limit <= 0 {
return rows
}
for i := range rows {
if len(rows[i].Items) > limit {
rows[i].Items = rows[i].Items[:limit]
}
}
return rows
}
func normalizeRecommendationLimit(limit int) int {
if limit <= 0 {
return 20
}
if limit > 20 {
return 20
}
return limit
}
func itoa(v int) string {
if v == 0 {
return "0"
}
buf := [20]byte{}
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}