Release v0.2.34
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user