This commit is contained in:
ponzischeme89
2026-08-19 21:30:44 +12:00
parent 0782545013
commit 769fe01c84
24 changed files with 1131 additions and 500 deletions
+3 -1
View File
@@ -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
-15
View File
@@ -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.
-31
View File
@@ -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 {
+66
View File
@@ -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)
}
}