439 lines
14 KiB
Go
439 lines
14 KiB
Go
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 []notificationResponse `json:"notifications"`
|
|
Preferences store.NotificationPreferences `json:"preferences"`
|
|
}
|
|
|
|
type notificationAction struct {
|
|
Kind string `json:"kind"`
|
|
Label string `json:"label"`
|
|
CompletedLabel string `json:"completedLabel"`
|
|
}
|
|
|
|
type notificationResponse struct {
|
|
store.UserNotification
|
|
Action *notificationAction `json:"action,omitempty"`
|
|
}
|
|
|
|
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
|
|
}
|
|
visible := filterStoredNotifications(notifications, prefs)
|
|
shows := []store.UserShow{}
|
|
if value, showsErr := s.store.UserShows(r.Context(), sess.EmbyUserID); showsErr == nil {
|
|
shows = value
|
|
} else {
|
|
s.loggerFor(r.Context()).Warn("My Shows unavailable for notification actions", "error", showsErr)
|
|
}
|
|
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 notifications", "error", seriesErr)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, notificationsResponse{
|
|
Notifications: notificationResponses(visible, shows, sonarrSeries),
|
|
Preferences: prefs,
|
|
})
|
|
}
|
|
|
|
func notificationResponses(
|
|
notifications []store.UserNotification, shows []store.UserShow, series []sonarr.Series,
|
|
) []notificationResponse {
|
|
showItemsBySeriesKey := map[string]string{}
|
|
for _, show := range shows {
|
|
matched := matchSonarrSeries(show, series)
|
|
if matched == nil {
|
|
continue
|
|
}
|
|
seriesKey := sonarrSeriesStatusKey(*matched)
|
|
if seriesKey == "" || strings.TrimSpace(show.ItemID) == "" {
|
|
continue
|
|
}
|
|
showItemsBySeriesKey[seriesKey] = show.ItemID
|
|
}
|
|
result := make([]notificationResponse, 0, len(notifications))
|
|
for _, notification := range notifications {
|
|
response := notificationResponse{UserNotification: notification}
|
|
if notification.Kind == "show-cancelled" {
|
|
if itemID := showItemsBySeriesKey[notificationSeriesKey(notification.SourceKey)]; itemID != "" {
|
|
response.ItemID = itemID
|
|
response.Action = ¬ificationAction{
|
|
Kind: "remove-my-show",
|
|
Label: "Remove from My Shows",
|
|
CompletedLabel: "Removed from My Shows",
|
|
}
|
|
}
|
|
}
|
|
result = append(result, response)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func notificationSeriesKey(sourceKey string) string {
|
|
const prefix = "show-cancelled:"
|
|
if !strings.HasPrefix(sourceKey, prefix) {
|
|
return ""
|
|
}
|
|
trimmed := strings.TrimPrefix(sourceKey, prefix)
|
|
cut := strings.LastIndex(trimmed, ":")
|
|
if cut <= 0 {
|
|
return ""
|
|
}
|
|
return trimmed[:cut]
|
|
}
|
|
|
|
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)
|
|
case "remove-my-show":
|
|
err = s.removeMyShowFromNotification(r, sess, 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 (s *Server) removeMyShowFromNotification(
|
|
r *http.Request, sess store.Session, notificationID int64,
|
|
) error {
|
|
notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
series := []sonarr.Series{}
|
|
if s.sonarrEnabled(r.Context()) {
|
|
value, err := s.sonarrSeriesCatalogue(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
series = value
|
|
}
|
|
for _, notification := range notificationResponses(notifications, shows, series) {
|
|
if notification.ID != notificationID || notification.Action == nil || notification.ItemID == "" {
|
|
continue
|
|
}
|
|
return s.store.DeleteUserShow(r.Context(), sess.EmbyUserID, notification.ItemID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|