Files
2026-08-19 14:25:44 +12:00

724 lines
24 KiB
Go

package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const tracearrImportStateKey = "tracearr_import_state"
type TracearrSessionKey struct {
ServerID string
SessionID string
}
type TracearrSessionSignal struct {
Fingerprint []byte
Terminal bool
}
type RecommendationIdentity struct {
TracearrUserID string
Username string
}
type TracearrSession struct {
ServerID string
SessionID string
UserID string
Username string
State string
MediaType string
MediaTitle string
ShowTitle string
SeasonNumber *int
EpisodeNumber *int
ProductionYear *int
StartedAt *time.Time
StoppedAt *time.Time
DurationMs int64
ProgressMs int64
TotalDurationMs int64
Watched bool
Device string
Player string
Product string
Platform string
IsTranscode bool
VideoDecision string
AudioDecision string
SourceVideoCodec string
SourceAudioCodec string
EmbyItemID string
EmbySeriesID string
SourceFingerprint []byte
}
type TracearrImportState struct {
LastIncrementalAt *time.Time `json:"lastIncrementalAt,omitempty"`
LastFullAt *time.Time `json:"lastFullAt,omitempty"`
LastError string `json:"lastError,omitempty"`
// LastRebuildAt is when the household's For You rows were last rebuilt in full.
//
// It lives in this document rather than in a table of its own because it is the same
// kind of fact as the two stamps above it — where the For You pipeline has got to —
// and because the daily rebuild is now a scheduled task, which means the alternative
// was inferring "did today's rebuild happen" from run history that also records the
// ticks on which it correctly declined to run.
LastRebuildAt *time.Time `json:"lastRebuildAt,omitempty"`
}
type RecommendationProfile struct {
EmbyUserID string
TracearrUserID string
TracearrUsername string
SourceSessionCount int
MeanCompletionRatio float64
TypicalSessionMinutes int
GenreAffinity json.RawMessage
TitleAffinity json.RawMessage
StudioAffinity json.RawMessage
ContextAffinity json.RawMessage
CodecOutcomes json.RawMessage
WeightedProfile json.RawMessage
AlgorithmVersion string
SignalsThrough *time.Time
BuiltAt time.Time
}
type ForYouCandidate struct {
ItemID string
BaseRank int
BaseScore float64
RuntimeMinutes int
AffinityScore float64
CompatibilityScore float64
CompatibilityLabel string
ReasonKind string
ReasonGenre string
ReasonSourceSessionID string
ReasonSourceItemID string
ReasonSourceTitle string
RecommendationReason string
}
type PreparedForYouItem struct {
ItemID string
BaseRank int
BaseScore float64
AffinityScore float64
Payload json.RawMessage
RuntimeMinutes int
CompatibilityScore float64
CompatibilityLabel string
RecommendationReason string
ReasonKind string
ReasonGenre string
ReasonSourceItemID string
ReasonSourceTitle string
ContextAffinity json.RawMessage
}
type ForYouStats struct {
TracearrSessions int64 `json:"tracearrSessions"`
Profiles int64 `json:"profiles"`
Candidates int64 `json:"candidates"`
LastFullImport *time.Time `json:"lastFullImport,omitempty"`
}
func (s *Store) TracearrSessionCount(ctx context.Context) (int64, error) {
var count int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM tracearr_sessions`).Scan(&count); err != nil {
return 0, fmt.Errorf("store: count tracearr sessions: %w", err)
}
return count, nil
}
func (s *Store) TracearrFingerprints(
ctx context.Context,
keys []TracearrSessionKey,
) (map[TracearrSessionKey][]byte, error) {
out := make(map[TracearrSessionKey][]byte, len(keys))
if len(keys) == 0 {
return out, nil
}
servers := make([]string, 0, len(keys))
ids := make([]string, 0, len(keys))
for _, key := range keys {
servers = append(servers, key.ServerID)
ids = append(ids, key.SessionID)
}
rows, err := s.pool.Query(ctx, `
SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint
FROM tracearr_sessions current
JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
ON current.server_id = wanted.server_id
AND current.tracearr_session_id = wanted.session_id`,
servers, ids)
if err != nil {
return nil, fmt.Errorf("store: tracearr fingerprints: %w", err)
}
defer rows.Close()
for rows.Next() {
var key TracearrSessionKey
var fingerprint []byte
if err := rows.Scan(&key.ServerID, &key.SessionID, &fingerprint); err != nil {
return nil, err
}
out[key] = fingerprint
}
return out, rows.Err()
}
func (s *Store) TracearrSessionSignals(
ctx context.Context,
keys []TracearrSessionKey,
) (map[TracearrSessionKey]TracearrSessionSignal, error) {
out := make(map[TracearrSessionKey]TracearrSessionSignal, len(keys))
if len(keys) == 0 {
return out, nil
}
servers := make([]string, 0, len(keys))
ids := make([]string, 0, len(keys))
for _, key := range keys {
servers = append(servers, key.ServerID)
ids = append(ids, key.SessionID)
}
rows, err := s.pool.Query(ctx, `
SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint,
(current.watched OR current.stopped_at IS NOT NULL OR
lower(current.state) IN ('stopped', 'completed', 'complete', 'ended'))
FROM tracearr_sessions current
JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
ON current.server_id = wanted.server_id
AND current.tracearr_session_id = wanted.session_id`,
servers, ids)
if err != nil {
return nil, fmt.Errorf("store: tracearr session signals: %w", err)
}
defer rows.Close()
for rows.Next() {
var key TracearrSessionKey
var signal TracearrSessionSignal
if err := rows.Scan(
&key.ServerID, &key.SessionID, &signal.Fingerprint, &signal.Terminal,
); err != nil {
return nil, err
}
out[key] = signal
}
return out, rows.Err()
}
func (s *Store) UpsertTracearrSessions(
ctx context.Context,
sessions []TracearrSession,
seenAt time.Time,
) error {
if len(sessions) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, session := range sessions {
batch.Queue(`
INSERT INTO tracearr_sessions (
server_id, tracearr_session_id, tracearr_user_id, username, state,
media_type, media_title, show_title, season_number, episode_number,
production_year, started_at, stopped_at, duration_ms, progress_ms,
total_duration_ms, watched, device, player, product, platform,
is_transcode, video_decision, audio_decision, source_video_codec,
source_audio_codec, emby_item_id, emby_series_id, source_fingerprint,
source_seen_at, imported_at, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,now(),now()
)
ON CONFLICT (server_id, tracearr_session_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
username = EXCLUDED.username,
state = EXCLUDED.state,
media_type = EXCLUDED.media_type,
media_title = EXCLUDED.media_title,
show_title = EXCLUDED.show_title,
season_number = EXCLUDED.season_number,
episode_number = EXCLUDED.episode_number,
production_year = EXCLUDED.production_year,
started_at = EXCLUDED.started_at,
stopped_at = EXCLUDED.stopped_at,
duration_ms = EXCLUDED.duration_ms,
progress_ms = EXCLUDED.progress_ms,
total_duration_ms = EXCLUDED.total_duration_ms,
watched = EXCLUDED.watched,
device = EXCLUDED.device,
player = EXCLUDED.player,
product = EXCLUDED.product,
platform = EXCLUDED.platform,
is_transcode = EXCLUDED.is_transcode,
video_decision = EXCLUDED.video_decision,
audio_decision = EXCLUDED.audio_decision,
source_video_codec = EXCLUDED.source_video_codec,
source_audio_codec = EXCLUDED.source_audio_codec,
source_fingerprint = EXCLUDED.source_fingerprint,
source_seen_at = EXCLUDED.source_seen_at,
updated_at = CASE
WHEN tracearr_sessions.source_fingerprint IS DISTINCT FROM EXCLUDED.source_fingerprint
THEN now() ELSE tracearr_sessions.updated_at END`,
session.ServerID, session.SessionID, session.UserID, session.Username,
session.State, session.MediaType, session.MediaTitle, session.ShowTitle,
session.SeasonNumber, session.EpisodeNumber, session.ProductionYear,
session.StartedAt, session.StoppedAt, session.DurationMs, session.ProgressMs,
session.TotalDurationMs, session.Watched, session.Device, session.Player,
session.Product, session.Platform, session.IsTranscode, session.VideoDecision,
session.AudioDecision, session.SourceVideoCodec, session.SourceAudioCodec,
session.EmbyItemID, session.EmbySeriesID, session.SourceFingerprint, seenAt)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
for range sessions {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("store: upsert tracearr sessions: %w", err)
}
}
return nil
}
func (s *Store) DeleteTracearrSessionsNotSeenSince(
ctx context.Context,
serverID string,
cutoff time.Time,
) (int64, error) {
if serverID == "" {
tag, err := s.pool.Exec(ctx, `DELETE FROM tracearr_sessions WHERE source_seen_at < $1`, cutoff)
if err != nil {
return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
}
return tag.RowsAffected(), nil
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM tracearr_sessions WHERE server_id = $1 AND source_seen_at < $2`,
serverID, cutoff)
if err != nil {
return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *Store) TracearrSessionsForUser(
ctx context.Context,
tracearrUserID, username string,
) ([]TracearrSession, error) {
rows, err := s.pool.Query(ctx, `
SELECT server_id, tracearr_session_id, tracearr_user_id, username, state,
media_type, media_title, show_title, season_number, episode_number,
production_year, started_at, stopped_at, duration_ms, progress_ms,
total_duration_ms, watched, device, player, product, platform,
is_transcode, video_decision, audio_decision, source_video_codec,
source_audio_codec, emby_item_id, emby_series_id, source_fingerprint
FROM tracearr_sessions
WHERE ($1 <> '' AND tracearr_user_id = $1)
OR ($1 = '' AND lower(username) = lower($2))
ORDER BY started_at DESC NULLS LAST, tracearr_session_id DESC`,
tracearrUserID, username)
if err != nil {
return nil, fmt.Errorf("store: tracearr user sessions: %w", err)
}
defer rows.Close()
out := []TracearrSession{}
for rows.Next() {
var session TracearrSession
if err := rows.Scan(
&session.ServerID, &session.SessionID, &session.UserID, &session.Username,
&session.State, &session.MediaType, &session.MediaTitle, &session.ShowTitle,
&session.SeasonNumber, &session.EpisodeNumber, &session.ProductionYear,
&session.StartedAt, &session.StoppedAt, &session.DurationMs, &session.ProgressMs,
&session.TotalDurationMs, &session.Watched, &session.Device, &session.Player,
&session.Product, &session.Platform, &session.IsTranscode,
&session.VideoDecision, &session.AudioDecision, &session.SourceVideoCodec,
&session.SourceAudioCodec, &session.EmbyItemID, &session.EmbySeriesID,
&session.SourceFingerprint,
); err != nil {
return nil, err
}
out = append(out, session)
}
return out, rows.Err()
}
func (s *Store) UpdateTracearrSessionMapping(
ctx context.Context,
key TracearrSessionKey,
itemID, seriesID string,
) error {
_, err := s.pool.Exec(ctx, `
UPDATE tracearr_sessions
SET emby_item_id = $3, emby_series_id = $4
WHERE server_id = $1 AND tracearr_session_id = $2
AND (emby_item_id, emby_series_id) IS DISTINCT FROM ($3, $4)`,
key.ServerID, key.SessionID, itemID, seriesID)
if err != nil {
return fmt.Errorf("store: map tracearr session: %w", err)
}
return nil
}
func (s *Store) ForYouTracearrIdentity(ctx context.Context, userID string) (string, string, error) {
var tracearrUserID, username string
err := s.pool.QueryRow(ctx, `
SELECT tracearr_user_id, tracearr_username
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
Scan(&tracearrUserID, &username)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("store: For You Tracearr identity: %w", err)
}
return tracearrUserID, username, nil
}
func (s *Store) TracearrImportState(ctx context.Context) (TracearrImportState, error) {
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, tracearrImportStateKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return TracearrImportState{}, nil
}
if err != nil {
return TracearrImportState{}, fmt.Errorf("store: read tracearr import state: %w", err)
}
var state TracearrImportState
if err := json.Unmarshal(raw, &state); err != nil {
return state, fmt.Errorf("store: decode tracearr import state: %w", err)
}
return state, nil
}
func (s *Store) SetTracearrImportState(ctx context.Context, state TracearrImportState) error {
raw, err := json.Marshal(state)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
tracearrImportStateKey, string(raw))
if err != nil {
return fmt.Errorf("store: write tracearr import state: %w", err)
}
return nil
}
// MarkForYouRebuild records that the household's rows have just been rebuilt.
//
// Read-modify-write rather than a whole-document put, because the importer owns the other
// two stamps in this document and an import running beside a rebuild must not lose its own.
func (s *Store) MarkForYouRebuild(ctx context.Context, at time.Time) error {
state, err := s.TracearrImportState(ctx)
if err != nil {
return err
}
stamp := at.UTC()
state.LastRebuildAt = &stamp
return s.SetTracearrImportState(ctx, state)
}
func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (emby_user_id)
token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, client_capabilities, last_seen_at
FROM sessions
ORDER BY emby_user_id, last_seen_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: active recommendation users: %w", err)
}
defer rows.Close()
out := []Session{}
for rows.Next() {
var session Session
if err := rows.Scan(
&session.TokenHash, &session.EmbyUserID, &session.EmbyToken, &session.Username,
&session.ServerID, &session.DeviceID, &session.DeviceName, &session.ClientVersion,
&session.ClientProtocol, &session.ClientCapabilities, &session.LastSeenAt,
); err != nil {
return nil, err
}
out = append(out, session)
}
return out, rows.Err()
}
func (s *Store) MarkForYouDirty(ctx context.Context, userID, username string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (emby_user_id, tracearr_username, dirty_since)
VALUES ($1, $2, now())
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_username = CASE WHEN $2 <> '' THEN $2 ELSE recommendation_user_profiles.tracearr_username END,
dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
userID, username)
if err != nil {
return fmt.Errorf("store: mark For You dirty: %w", err)
}
return nil
}
func (s *Store) MarkAllForYouProfilesDirty(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `
UPDATE recommendation_user_profiles
SET dirty_since = coalesce(dirty_since, now())`)
if err != nil {
return fmt.Errorf("store: mark all For You profiles dirty: %w", err)
}
return nil
}
func (s *Store) MarkForYouProfilesDirtyByTracearrIdentity(
ctx context.Context,
identities []RecommendationIdentity,
) (int64, error) {
if len(identities) == 0 {
return 0, nil
}
userIDs := make([]string, 0, len(identities))
usernames := make([]string, 0, len(identities))
for _, identity := range identities {
if identity.TracearrUserID != "" {
userIDs = append(userIDs, identity.TracearrUserID)
}
if identity.Username != "" {
usernames = append(usernames, strings.ToLower(identity.Username))
}
}
tag, err := s.pool.Exec(ctx, `
UPDATE recommendation_user_profiles
SET dirty_since = coalesce(dirty_since, now())
WHERE tracearr_user_id = ANY($1)
OR lower(tracearr_username) = ANY($2)`,
userIDs, usernames)
if err != nil {
return 0, fmt.Errorf("store: mark affected For You profiles dirty: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *Store) MatchRecommendationUser(
ctx context.Context,
embyUserID, embyUsername, tracearrUserID, tracearrUsername string,
) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (
emby_user_id, tracearr_user_id, tracearr_username, dirty_since
) VALUES ($1, $2, CASE WHEN $3 <> '' THEN $3 ELSE $4 END, now())
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
tracearr_username = CASE
WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username
ELSE $4
END,
dirty_since = CASE
WHEN recommendation_user_profiles.tracearr_user_id IS DISTINCT FROM EXCLUDED.tracearr_user_id
OR recommendation_user_profiles.tracearr_username IS DISTINCT FROM
CASE WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username ELSE $4 END
THEN coalesce(recommendation_user_profiles.dirty_since, now())
ELSE recommendation_user_profiles.dirty_since
END`,
embyUserID, tracearrUserID, tracearrUsername, embyUsername)
if err != nil {
return fmt.Errorf("store: match recommendation user: %w", err)
}
return nil
}
func (s *Store) ForYouProfileTimes(
ctx context.Context,
userID string,
) (builtAt, poolBuiltAt, dirtySince *time.Time, algorithmVersion string, err error) {
err = s.pool.QueryRow(ctx, `
SELECT built_at, pool_built_at, dirty_since, algorithm_version
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
Scan(&builtAt, &poolBuiltAt, &dirtySince, &algorithmVersion)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil, "", nil
}
if err != nil {
return nil, nil, nil, "", fmt.Errorf("store: For You freshness: %w", err)
}
return builtAt, poolBuiltAt, dirtySince, algorithmVersion, nil
}
func (s *Store) SetForYouError(ctx context.Context, userID string, buildErr error) error {
message := ""
if buildErr != nil {
message = buildErr.Error()
}
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (emby_user_id, last_error, dirty_since)
VALUES ($1, $2, now())
ON CONFLICT (emby_user_id) DO UPDATE SET last_error = $2,
dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
userID, message)
return err
}
func (s *Store) ReplaceForYouPool(
ctx context.Context,
profile RecommendationProfile,
candidates []ForYouCandidate,
) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin For You rebuild: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, `
INSERT INTO recommendation_user_profiles (
emby_user_id, tracearr_user_id, tracearr_username, source_session_count,
mean_completion_ratio, typical_session_minutes, genre_affinity,
title_affinity, studio_affinity, context_affinity, codec_outcomes,
weighted_profile, algorithm_version, signals_through, built_at, pool_built_at, dirty_since, last_error
) VALUES (
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11::jsonb,
$12::jsonb,$13,$14,$15,$15,NULL,''
)
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
tracearr_username = EXCLUDED.tracearr_username,
source_session_count = EXCLUDED.source_session_count,
mean_completion_ratio = EXCLUDED.mean_completion_ratio,
typical_session_minutes = EXCLUDED.typical_session_minutes,
genre_affinity = EXCLUDED.genre_affinity,
title_affinity = EXCLUDED.title_affinity,
studio_affinity = EXCLUDED.studio_affinity,
context_affinity = EXCLUDED.context_affinity,
codec_outcomes = EXCLUDED.codec_outcomes,
weighted_profile = EXCLUDED.weighted_profile,
algorithm_version = EXCLUDED.algorithm_version,
signals_through = EXCLUDED.signals_through,
built_at = EXCLUDED.built_at,
pool_built_at = EXCLUDED.pool_built_at,
dirty_since = NULL,
last_error = ''`,
profile.EmbyUserID, profile.TracearrUserID, profile.TracearrUsername,
profile.SourceSessionCount, profile.MeanCompletionRatio,
profile.TypicalSessionMinutes, string(profile.GenreAffinity),
string(profile.TitleAffinity), string(profile.StudioAffinity),
string(profile.ContextAffinity), string(profile.CodecOutcomes),
string(profile.WeightedProfile), profile.AlgorithmVersion,
profile.SignalsThrough, profile.BuiltAt)
if err != nil {
return fmt.Errorf("store: write For You profile: %w", err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM for_you_candidates WHERE emby_user_id = $1`, profile.EmbyUserID); err != nil {
return fmt.Errorf("store: clear For You pool: %w", err)
}
batch := &pgx.Batch{}
for _, candidate := range candidates {
batch.Queue(`
INSERT INTO for_you_candidates (
emby_user_id, item_id, base_rank, base_score, runtime_minutes,
affinity_score, compatibility_score, compatibility_label, reason_kind,
reason_genre, reason_source_session_id, reason_source_item_id,
reason_source_title, recommendation_reason, built_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
profile.EmbyUserID, candidate.ItemID, candidate.BaseRank, candidate.BaseScore,
candidate.RuntimeMinutes, candidate.AffinityScore, candidate.CompatibilityScore,
candidate.CompatibilityLabel, candidate.ReasonKind, candidate.ReasonGenre,
candidate.ReasonSourceSessionID, candidate.ReasonSourceItemID,
candidate.ReasonSourceTitle, candidate.RecommendationReason, profile.BuiltAt)
}
results := tx.SendBatch(ctx, batch)
for range candidates {
if _, err := results.Exec(); err != nil {
_ = results.Close()
return fmt.Errorf("store: insert For You candidates: %w", err)
}
}
if err := results.Close(); err != nil {
return fmt.Errorf("store: close For You candidate batch: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit For You rebuild: %w", err)
}
return nil
}
func (s *Store) PreparedForYou(
ctx context.Context,
userID string,
minutes, limit int,
) ([]PreparedForYouItem, *time.Time, error) {
rows, err := s.pool.Query(ctx, `
SELECT fc.item_id, fc.base_rank, fc.base_score, fc.affinity_score,
li.payload, fc.runtime_minutes,
fc.compatibility_score, fc.compatibility_label,
fc.recommendation_reason, fc.reason_kind, fc.reason_genre,
fc.reason_source_item_id, fc.reason_source_title, p.context_affinity,
p.pool_built_at
FROM for_you_candidates fc
JOIN library_items li ON li.id = fc.item_id
JOIN recommendation_user_profiles p ON p.emby_user_id = fc.emby_user_id
WHERE fc.emby_user_id = $1
AND (
$2 = 0 OR fc.reason_kind = 'pick-up' OR
(fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2)
)
ORDER BY fc.base_rank
LIMIT $3`,
userID, minutes, limit)
if err != nil {
return nil, nil, fmt.Errorf("store: prepared For You: %w", err)
}
defer rows.Close()
out := []PreparedForYouItem{}
var builtAt *time.Time
for rows.Next() {
var payload []byte
var item PreparedForYouItem
var rowBuiltAt *time.Time
if err := rows.Scan(
&item.ItemID, &item.BaseRank, &item.BaseScore, &item.AffinityScore,
&payload, &item.RuntimeMinutes,
&item.CompatibilityScore, &item.CompatibilityLabel,
&item.RecommendationReason, &item.ReasonKind, &item.ReasonGenre,
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &item.ContextAffinity,
&rowBuiltAt,
); err != nil {
return nil, nil, err
}
item.Payload = json.RawMessage(payload)
out = append(out, item)
if builtAt == nil {
builtAt = rowBuiltAt
}
}
return out, builtAt, rows.Err()
}
func (s *Store) ForYouStats(ctx context.Context) (ForYouStats, error) {
var stats ForYouStats
if err := s.pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM tracearr_sessions),
(SELECT count(*) FROM recommendation_user_profiles),
(SELECT count(*) FROM for_you_candidates)`).
Scan(&stats.TracearrSessions, &stats.Profiles, &stats.Candidates); err != nil {
return stats, fmt.Errorf("store: For You stats: %w", err)
}
state, err := s.TracearrImportState(ctx)
if err != nil {
return stats, err
}
stats.LastFullImport = state.LastFullAt
return stats, nil
}