package store import ( "context" "fmt" "time" ) // Search history is the record of what a household looks for, and it has two readers with // quite different appetites: a television asking for one viewer's last few queries, and // the console asking what the house as a whole has been searching. Both read the one // table, which is why the writer's rules live here beside them. // SearchDedupeWindow is how long an identical query counts as the same search. // // Two things write this table for one search — the gateway's own /v1/search handler and // the client's POST to /v1/search/history — and a television that repeats a query while // somebody re-reads the results is not a second search either. The window is short enough // that a query typed again a minute later is its own row, which is what makes the table a // record of what a household looks for rather than of how its remote behaves. const SearchDedupeWindow = 30 * time.Second // SearchRetention is how far back the table goes. RecordSearch prunes to it on every // write, so it is also the honest ceiling on any window the console offers: a page // promising 90 days would draw a flat line for two thirds of it. const SearchRetention = 30 * 24 * time.Hour // RecordSearch stores a normalized query for future per-user ranking analysis. // // Case-insensitive within the dedupe window, matching RecentSearches, which collapses // case-only duplicates when it reads them back. func (s *Store) RecordSearch(ctx context.Context, userID, query string) error { _, err := s.pool.Exec(ctx, `WITH inserted AS ( INSERT INTO search_history (emby_user_id, query) SELECT $1, $2 WHERE NOT EXISTS ( SELECT 1 FROM search_history WHERE emby_user_id = $1 AND lower(query) = lower($2) AND occurred_at > now() - $3::interval ) RETURNING id ) DELETE FROM search_history WHERE emby_user_id = $1 AND occurred_at < now() - $4::interval`, userID, query, SearchDedupeWindow.String(), SearchRetention.String()) return err } // RecentSearches returns a user's distinct queries in most-recently-used order. // Case-only duplicates collapse to the spelling used most recently. func (s *Store) RecentSearches( ctx context.Context, userID string, since time.Time, limit int, ) ([]string, error) { rows, err := s.pool.Query(ctx, ` SELECT query FROM ( SELECT DISTINCT ON (lower(query)) query, occurred_at FROM search_history WHERE emby_user_id = $1 AND occurred_at >= $2 ORDER BY lower(query), occurred_at DESC ) AS latest ORDER BY occurred_at DESC LIMIT $3`, userID, since, limit) if err != nil { return nil, fmt.Errorf("store: recent searches: %w", err) } defer rows.Close() queries := make([]string, 0, limit) for rows.Next() { var query string if err := rows.Scan(&query); err != nil { return nil, fmt.Errorf("store: scan recent search: %w", err) } queries = append(queries, query) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: read recent searches: %w", err) } return queries, nil } // SearchTerm is one query the household searched for, aggregated across everyone. type SearchTerm struct { Query string `json:"query"` Searches int `json:"searches"` Viewers int `json:"viewers"` LastAt time.Time `json:"lastAt"` } // SearchEvent is one search as it happened: the log rather than the summary. type SearchEvent struct { Query string `json:"query"` UserID string `json:"userId"` Username string `json:"username"` OccurredAt time.Time `json:"occurredAt"` } // SearchTotals describes a window as a whole. Counted separately from SearchTerms because // that list is capped — summing a top-twenty would report the top twenty's total as the // household's, which is wrong by however long the tail is. type SearchTotals struct { Searches int `json:"searches"` Queries int `json:"queries"` Viewers int `json:"viewers"` } // SearchTerms aggregates the household's queries since a point in time, most-searched // first. Grouped case-insensitively and labelled with the spelling used most recently, // the same rule RecentSearches applies, so one query cannot appear as two rows because // somebody's on-screen keyboard capitalised it. func (s *Store) SearchTerms(ctx context.Context, since time.Time, limit int) ([]SearchTerm, error) { rows, err := s.pool.Query(ctx, ` SELECT (array_agg(query ORDER BY occurred_at DESC))[1] AS query, count(*) AS searches, count(DISTINCT emby_user_id) AS viewers, max(occurred_at) AS last_at FROM search_history WHERE occurred_at >= $1 GROUP BY lower(query) ORDER BY searches DESC, last_at DESC LIMIT $2`, since, limit) if err != nil { return nil, fmt.Errorf("store: search terms: %w", err) } defer rows.Close() terms := []SearchTerm{} for rows.Next() { var term SearchTerm if err := rows.Scan(&term.Query, &term.Searches, &term.Viewers, &term.LastAt); err != nil { return nil, fmt.Errorf("store: scan search term: %w", err) } terms = append(terms, term) } return terms, rows.Err() } // SearchEvents returns the raw log, newest first. // // Deliberately not collapsed: the summary above answers "what does this house look for", // and this answers "what happened just now" — which is the one an operator needs when // somebody says search is not finding something, because it shows the query exactly as it // was typed, by whom, and at what time. Usernames are resolved by the caller from // KnownUsers: they live in sessions and joining a log to them per row would make the // query's cost depend on how many televisions the household has ever signed in. func (s *Store) SearchEvents(ctx context.Context, since time.Time, limit int) ([]SearchEvent, error) { rows, err := s.pool.Query(ctx, ` SELECT query, emby_user_id, occurred_at FROM search_history WHERE occurred_at >= $1 ORDER BY occurred_at DESC LIMIT $2`, since, limit) if err != nil { return nil, fmt.Errorf("store: search events: %w", err) } defer rows.Close() events := []SearchEvent{} for rows.Next() { var event SearchEvent if err := rows.Scan(&event.Query, &event.UserID, &event.OccurredAt); err != nil { return nil, fmt.Errorf("store: scan search event: %w", err) } events = append(events, event) } return events, rows.Err() } // SearchTotals counts a window: searches made, distinct queries behind them, and how many // of the household did the searching. func (s *Store) SearchTotals(ctx context.Context, since time.Time) (SearchTotals, error) { var totals SearchTotals err := s.pool.QueryRow(ctx, ` SELECT count(*), count(DISTINCT lower(query)), count(DISTINCT emby_user_id) FROM search_history WHERE occurred_at >= $1`, since). Scan(&totals.Searches, &totals.Queries, &totals.Viewers) if err != nil { return SearchTotals{}, fmt.Errorf("store: search totals: %w", err) } return totals, nil }