package api import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) type myShowResponse struct { store.UserShow SonarrStatus string `json:"sonarrStatus"` NextEpisode *time.Time `json:"nextEpisode,omitempty"` Lifecycle string `json:"lifecycle"` Monitored bool `json:"monitored"` } type myShowsResponse struct { Shows []myShowResponse `json:"shows"` } type notificationsResponse struct { Notifications []store.UserNotification `json:"notifications"` Preferences store.NotificationPreferences `json:"preferences"` } func (s *Server) handleMyShows(w http.ResponseWriter, r *http.Request, sess store.Session) { switch r.Method { case http.MethodGet: s.listMyShows(w, r, sess) case http.MethodPost: var show store.UserShow if !decodeJSON(w, r, &show) { return } show.ItemID = strings.TrimSpace(show.ItemID) show.Title = strings.TrimSpace(show.Title) if show.ItemID == "" || show.Title == "" { writeError(w, http.StatusBadRequest, "itemId and title are required") return } if err := s.store.SaveUserShow(r.Context(), sess.EmbyUserID, show); err != nil { s.loggerFor(r.Context()).Error("save user show failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save show") return } s.listMyShows(w, r, sess) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (s *Server) handleMyShow(w http.ResponseWriter, r *http.Request, sess store.Session) { itemID := strings.TrimSpace(r.PathValue("id")) if itemID == "" { writeError(w, http.StatusBadRequest, "show id is required") return } if err := s.store.DeleteUserShow(r.Context(), sess.EmbyUserID, itemID); err != nil { s.loggerFor(r.Context()).Error("delete user show failed", "error", err) writeError(w, http.StatusInternalServerError, "could not remove show") return } w.WriteHeader(http.StatusNoContent) } func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.Session) { saved, err := s.store.UserShows(r.Context(), sess.EmbyUserID) if err != nil { s.loggerFor(r.Context()).Error("list user shows failed", "error", err) writeError(w, http.StatusInternalServerError, "could not load shows") return } sonarrSeries := []sonarr.Series{} if s.sonarrEnabled(r.Context()) { if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil { sonarrSeries = value } else { s.loggerFor(r.Context()).Warn("Sonarr status unavailable for My Shows", "error", seriesErr) } } result := make([]myShowResponse, 0, len(saved)) for _, show := range saved { matched := matchSonarrSeries(show, sonarrSeries) response := myShowResponse{ UserShow: show, SonarrStatus: "Not found", Lifecycle: "Unknown", } if matched != nil { response.SonarrStatus = sonarrStatus(*matched) response.NextEpisode = matched.NextAiring response.Lifecycle = seriesLifecycle(matched.Status) response.Monitored = matched.Monitored } result = append(result, response) } writeJSON(w, http.StatusOK, myShowsResponse{Shows: result}) } func matchSonarrSeries(show store.UserShow, all []sonarr.Series) *sonarr.Series { key := normalizedShowTitle(show.Title) for i := range all { candidate := &all[i] if normalizedShowTitle(candidate.Title) != key { continue } if show.Year == nil || candidate.Year == 0 || candidate.Year == *show.Year { return candidate } } return nil } func normalizedShowTitle(value string) string { return strings.Map(func(r rune) rune { if r >= 'A' && r <= 'Z' { return r + ('a' - 'A') } if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { return r } return -1 }, value) } func seriesLifecycle(status string) string { switch strings.ToLower(status) { case "continuing", "upcoming": return "Continuing" case "ended", "deleted": return "Cancelled" default: if status == "" { return "Unknown" } return strings.ToUpper(status[:1]) + strings.ToLower(status[1:]) } } func isContinuingSonarrStatus(status string) bool { return strings.EqualFold(status, "continuing") || strings.EqualFold(status, "upcoming") } func sonarrStatus(series sonarr.Series) string { if series.Monitored { return "Monitored" } return "Not monitored" } func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, sess store.Session) { prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID) if err != nil { writeError(w, http.StatusInternalServerError, "could not load notification preferences") return } if r.Method == http.MethodPut { if !decodeJSON(w, r, &prefs) { return } if err := s.saveNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil { writeError(w, http.StatusInternalServerError, "could not save notification preferences") return } } if prefs.Enabled && prefs.SonarrAlerts && prefs.ShowReturnAlerts { s.syncReturnNotifications(r, sess, prefs) } notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID) if err != nil { writeError(w, http.StatusInternalServerError, "could not load notifications") return } writeJSON(w, http.StatusOK, notificationsResponse{ Notifications: filterStoredNotifications(notifications, prefs), Preferences: prefs, }) } func (s *Server) syncReturnNotifications( r *http.Request, sess store.Session, prefs store.NotificationPreferences, ) { if !s.sonarrEnabled(r.Context()) { return } shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID) if err != nil { return } all, err := s.sonarrSeriesCatalogue(r.Context()) if err != nil { return } now := time.Now() until := now.AddDate(0, 0, prefs.LeadDays) for _, show := range shows { series := matchSonarrSeries(show, all) if series == nil || series.NextAiring == nil || series.NextAiring.Before(now) || series.NextAiring.After(until) { continue } days := int(series.NextAiring.Sub(now).Hours()/24) + 1 message := fmt.Sprintf("%s returns in %d days.", show.Title, days) if days <= 1 { message = show.Title + " returns tomorrow." } else if days == 7 { message = show.Title + " returns next week." } sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02") s.notifyUser(r.Context(), notify.Notification{ Kind: "show-return", Source: notifySourceShowReturn, UserID: sess.EmbyUserID, Username: sess.Username, Title: "New episode coming", Body: message, ItemID: show.ItemID, SourceKey: sourceKey, EventAt: series.NextAiring, Metadata: map[string]any{"show": show.Title, "leadDays": prefs.LeadDays}, }) } } // 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, ) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id < 1 { writeError(w, http.StatusBadRequest, "invalid notification id") return } switch r.PathValue("action") { case "read": err = s.store.MarkNotificationRead(r.Context(), sess.EmbyUserID, id) // Marking a notification back to new is the viewer's own action, where "read" is set by // the page merely focusing a row. That is why the two are separate routes rather than one // carrying a boolean: an automatic mark and a deliberate one are different events, and only // this one is ever a decision somebody made with the remote. case "unread": err = s.store.MarkNotificationUnread(r.Context(), sess.EmbyUserID, id) case "dismiss": err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id) default: writeError(w, http.StatusNotFound, "unknown notification action") return } if err != nil { writeError(w, http.StatusInternalServerError, "could not update notification") return } w.WriteHeader(http.StatusNoContent) } func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool { decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) decoder.DisallowUnknownFields() if err := decoder.Decode(out); err != nil { writeError(w, http.StatusBadRequest, "invalid request") return false } return true }