0.2.80
This commit is contained in:
@@ -272,7 +272,6 @@ func (s *Server) Routes() http.Handler {
|
||||
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
|
||||
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
|
||||
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
|
||||
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
|
||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
||||
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
||||
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
|
||||
@@ -291,6 +290,9 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow))
|
||||
v1.Handle("GET /v1/notifications", s.authed(s.handleNotifications))
|
||||
v1.Handle("PUT /v1/notifications", s.authed(s.handleNotifications))
|
||||
// Ahead of the per-alert route: three path segments rather than four, so the two never
|
||||
// compete, and a shortcut that clears the lot needs one request and one log line.
|
||||
v1.Handle("POST /v1/notifications/clear", s.authed(s.handleClearNotifications))
|
||||
v1.Handle("POST /v1/notifications/{id}/{action}", s.authed(s.handleNotificationAction))
|
||||
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
|
||||
// A viewer's settings follow the person, not the television. Both verbs land on one
|
||||
|
||||
@@ -298,21 +298,6 @@ func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) {
|
||||
resp := searchHistoryResponse{Queries: []string{}}
|
||||
body, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := decoded["queries"].([]any); !ok {
|
||||
t.Fatalf("queries encoded as %T, want array", decoded["queries"])
|
||||
}
|
||||
}
|
||||
|
||||
// Both routes that write search_history apply one rule, so a query /v1/search records is
|
||||
// exactly one /v1/search/history would have accepted. The length is counted in runes:
|
||||
// bytes would reject a Japanese title at a third of an English one's length.
|
||||
|
||||
@@ -664,37 +664,6 @@ type searchHistoryRequest struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type searchHistoryResponse struct {
|
||||
Queries []string `json:"queries"`
|
||||
}
|
||||
|
||||
const (
|
||||
recentSearchDays = 30
|
||||
recentSearchLimit = 10
|
||||
)
|
||||
|
||||
func (s *Server) handleRecentSearches(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
if s.store == nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load recent searches")
|
||||
return
|
||||
}
|
||||
since := time.Now().Add(-recentSearchDays * 24 * time.Hour)
|
||||
queries, err := s.store.RecentSearches(
|
||||
r.Context(),
|
||||
sess.EmbyUserID,
|
||||
since,
|
||||
recentSearchLimit,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load recent searches")
|
||||
return
|
||||
}
|
||||
if queries == nil {
|
||||
queries = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, searchHistoryResponse{Queries: queries})
|
||||
}
|
||||
|
||||
func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
var req searchHistoryRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
|
||||
|
||||
@@ -229,6 +229,72 @@ func (s *Server) syncReturnNotifications(
|
||||
}
|
||||
}
|
||||
|
||||
// clearNotificationsResponse says how many of this viewer's notifications the gateway
|
||||
// actually cleared. The television prints the figure back as its confirmation, so it must be
|
||||
// what happened rather than what was asked for.
|
||||
type clearNotificationsResponse struct {
|
||||
Cleared int `json:"cleared"`
|
||||
}
|
||||
|
||||
// handleClearNotifications empties one viewer's list in a single request.
|
||||
//
|
||||
// It exists because clearing from the user picker is a shortcut for somebody who does not
|
||||
// want to go into the page at all, and a television looping the per-alert dismiss route
|
||||
// could neither report a trustworthy count nor leave one line in the log an operator could
|
||||
// read. The two rules worth preserving:
|
||||
//
|
||||
// - What it clears is what that viewer can *see*. filterStoredNotifications is what the
|
||||
// list route already applies, so a summary their preferences have withdrawn is not
|
||||
// quietly dismissed underneath them by a press aimed at the seven alerts on screen —
|
||||
// and the count agrees with the badge that was showing.
|
||||
// - Nothing to clear is a success, not an error. It answers 0 and says so, because the
|
||||
// television disables the action on an empty list and a race with another set finishing
|
||||
// the job first is not a failure anybody should be shown.
|
||||
//
|
||||
// clearableNotificationIDs is the rows a clear-all press may take: exactly the ones the
|
||||
// list route would have shown this viewer, and nothing their preferences have withdrawn.
|
||||
//
|
||||
// Pure and separate from the handler so the one rule that matters here — a press aimed at
|
||||
// what is on screen never reaches past it — is pinned by a test rather than by a database.
|
||||
func clearableNotificationIDs(
|
||||
notifications []store.UserNotification, prefs store.NotificationPreferences,
|
||||
) []int64 {
|
||||
visible := filterStoredNotifications(notifications, prefs)
|
||||
ids := make([]int64, 0, len(visible))
|
||||
for _, notification := range visible {
|
||||
ids = append(ids, notification.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *Server) handleClearNotifications(
|
||||
w http.ResponseWriter, r *http.Request, sess store.Session,
|
||||
) {
|
||||
log := s.loggerFor(r.Context())
|
||||
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
|
||||
if err != nil {
|
||||
log.Warn("notifications not cleared", "reason", "preferences unavailable", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not load notification preferences")
|
||||
return
|
||||
}
|
||||
notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID)
|
||||
if err != nil {
|
||||
log.Warn("notifications not cleared", "reason", "list unavailable", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not load notifications")
|
||||
return
|
||||
}
|
||||
ids := clearableNotificationIDs(notifications, prefs)
|
||||
cleared, err := s.store.DismissNotifications(r.Context(), sess.EmbyUserID, ids)
|
||||
if err != nil {
|
||||
log.Warn("notifications not cleared", "reason", "write failed",
|
||||
"requested", len(ids), "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not clear notifications")
|
||||
return
|
||||
}
|
||||
log.Info("notifications cleared", "cleared", cleared, "source", "user-switcher")
|
||||
writeJSON(w, http.StatusOK, clearNotificationsResponse{Cleared: cleared})
|
||||
}
|
||||
|
||||
func (s *Server) handleNotificationAction(
|
||||
w http.ResponseWriter, r *http.Request, sess store.Session,
|
||||
) {
|
||||
|
||||
@@ -55,3 +55,29 @@ func TestUpdatePreferenceNeverSuppressesMandatoryUpdate(t *testing.T) {
|
||||
t.Fatalf("optional update was not suppressed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A clear-all press aimed at the alerts on screen must never reach past them. The one case
|
||||
// that can differ is a kind the viewer's own preferences have withdrawn: it is still a row
|
||||
// in the table, it is not in their list, and clearing it would be this shortcut deciding
|
||||
// something the viewer never saw.
|
||||
func TestClearableNotificationIDsHonourPreferences(t *testing.T) {
|
||||
notifications := []store.UserNotification{
|
||||
{ID: 1, Kind: "show-return"},
|
||||
{ID: 2, Kind: watchTimeWeeklyKind},
|
||||
{ID: 3, Kind: "library-added"},
|
||||
}
|
||||
prefs := store.DefaultNotificationPreferences()
|
||||
prefs.WatchTimeDigest = false
|
||||
ids := clearableNotificationIDs(notifications, prefs)
|
||||
if len(ids) != 2 || ids[0] != 1 || ids[1] != 3 {
|
||||
t.Fatalf("expected the two visible rows, got %v", ids)
|
||||
}
|
||||
|
||||
prefs.Enabled = false
|
||||
if ids := clearableNotificationIDs(notifications, prefs); len(ids) != 0 {
|
||||
t.Fatalf("notifications switched off should clear nothing, got %v", ids)
|
||||
}
|
||||
if ids := clearableNotificationIDs(nil, store.DefaultNotificationPreferences()); len(ids) != 0 {
|
||||
t.Fatalf("an empty list should clear nothing, got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,3 +354,28 @@ func (s *Store) DismissNotification(ctx context.Context, userID string, id int64
|
||||
WHERE id = $1 AND emby_user_id = $2`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DismissNotifications clears several of one viewer's notifications at once and reports how
|
||||
// many rows it actually took.
|
||||
//
|
||||
// The count is the whole reason this is a route rather than the client's loop: "cleared 7"
|
||||
// is what the log line and the activity record are worth reading for, and a television
|
||||
// counting the requests it made would be counting what it asked for rather than what
|
||||
// happened — a row somebody dismissed on another set in the meantime is one this must not
|
||||
// claim. Already-dismissed rows are excluded rather than re-stamped, so a repeated press
|
||||
// honestly reports nothing left to clear.
|
||||
func (s *Store) DismissNotifications(
|
||||
ctx context.Context, userID string, ids []int64,
|
||||
) (int, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE user_notifications SET dismissed_at = now()
|
||||
WHERE emby_user_id = $1 AND id = ANY($2::bigint[]) AND dismissed_at IS NULL`,
|
||||
userID, ids)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: dismiss notifications: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@ import (
|
||||
"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.
|
||||
// Search history is the record of what a household looks for, read by the console as the
|
||||
// summary of what the house searches for and as the uncollapsed log of what happened just
|
||||
// now. The writer's rules live here beside those readers.
|
||||
|
||||
// SearchDedupeWindow is how long an identical query counts as the same search.
|
||||
//
|
||||
@@ -27,8 +26,8 @@ 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.
|
||||
// Case-insensitive within the dedupe window, so a query retyped with a different
|
||||
// capitalisation is not a second search.
|
||||
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`WITH inserted AS (
|
||||
@@ -49,44 +48,6 @@ func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
||||
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"`
|
||||
@@ -114,8 +75,8 @@ type SearchTotals struct {
|
||||
|
||||
// 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.
|
||||
// the same rule the writer's dedupe window 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,
|
||||
|
||||
Reference in New Issue
Block a user