package api import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "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.log.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.log.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.log.Error("list user shows failed", "error", err) writeError(w, http.StatusInternalServerError, "could not load shows") return } sonarrSeries := []sonarr.Series{} if s.sonarr != nil { if value, seriesErr := s.sonarr.Series(r.Context()); seriesErr == nil { sonarrSeries = value } else { s.log.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.store.NotificationPreferences(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.store.SetNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil { writeError(w, http.StatusInternalServerError, "could not save notification preferences") return } } if prefs.Enabled && 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: notifications, Preferences: prefs, }) } func (s *Server) syncReturnNotifications( r *http.Request, sess store.Session, prefs store.NotificationPreferences, ) { if s.sonarr == nil { return } shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID) if err != nil { return } all, err := s.sonarr.Series(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.store.UpsertNotification( r.Context(), sess.EmbyUserID, sourceKey, "show-return", show.ItemID, "New episode coming", message, series.NextAiring, ) } } 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) 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 }