Files
memby/server/internal/api/my_shows.go
T

271 lines
8.1 KiB
Go
Raw Normal View History

2026-08-02 22:10:19 +12:00
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
2026-08-19 06:57:59 +12:00
"github.com/ponzischeme89/memby/server/internal/notify"
2026-08-02 22:10:19 +12:00
"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 {
2026-08-06 22:33:56 +12:00
s.loggerFor(r.Context()).Error("save user show failed", "error", err)
2026-08-02 22:10:19 +12:00
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 {
2026-08-06 22:33:56 +12:00
s.loggerFor(r.Context()).Error("delete user show failed", "error", err)
2026-08-02 22:10:19 +12:00
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 {
2026-08-06 22:33:56 +12:00
s.loggerFor(r.Context()).Error("list user shows failed", "error", err)
2026-08-02 22:10:19 +12:00
writeError(w, http.StatusInternalServerError, "could not load shows")
return
}
sonarrSeries := []sonarr.Series{}
2026-08-14 11:47:32 +12:00
if s.sonarrEnabled(r.Context()) {
2026-08-09 08:25:50 +12:00
if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil {
2026-08-02 22:10:19 +12:00
sonarrSeries = value
} else {
2026-08-06 22:33:56 +12:00
s.loggerFor(r.Context()).Warn("Sonarr status unavailable for My Shows", "error", seriesErr)
2026-08-02 22:10:19 +12:00
}
}
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) {
2026-08-15 09:23:26 +12:00
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
2026-08-02 22:10:19 +12:00
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load notification preferences")
return
}
if r.Method == http.MethodPut {
if !decodeJSON(w, r, &prefs) {
return
}
2026-08-15 09:23:26 +12:00
if err := s.saveNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil {
2026-08-02 22:10:19 +12:00
writeError(w, http.StatusInternalServerError, "could not save notification preferences")
return
}
}
2026-08-15 09:23:26 +12:00
if prefs.Enabled && prefs.SonarrAlerts && prefs.ShowReturnAlerts {
2026-08-02 22:10:19 +12:00
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{
2026-08-15 09:23:26 +12:00
Notifications: filterStoredNotifications(notifications, prefs),
2026-08-02 22:10:19 +12:00
Preferences: prefs,
})
}
func (s *Server) syncReturnNotifications(
r *http.Request, sess store.Session, prefs store.NotificationPreferences,
) {
2026-08-14 11:47:32 +12:00
if !s.sonarrEnabled(r.Context()) {
2026-08-02 22:10:19 +12:00
return
}
shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID)
if err != nil {
return
}
2026-08-09 08:25:50 +12:00
all, err := s.sonarrSeriesCatalogue(r.Context())
2026-08-02 22:10:19 +12:00
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")
2026-08-19 06:57:59 +12:00
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},
})
2026-08-02 22:10:19 +12:00
}
}
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)
2026-08-19 06:57:59 +12:00
// 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)
2026-08-02 22:10:19 +12:00
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
}