package api import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/ponzischeme89/memby/server/internal/adminevents" "github.com/ponzischeme89/memby/server/internal/store" ) var mediaReportReasons = map[string]bool{ "playback_problem": true, "poor_video_quality": true, "poor_audio_quality": true, "audio_out_of_sync": true, "wrong_media": true, "missing_or_corrupt": true, "subtitles_problem": true, "file_stops": true, "request_better_copy": true, "other": true, } type mediaReportPayload struct { Reason string `json:"reason"` Comment string `json:"comment"` RequestReplacement bool `json:"requestReplacement"` } type reportItem struct { Type string `json:"Type"` Name string `json:"Name"` SeriesName string `json:"SeriesName"` SeriesID string `json:"SeriesId"` ParentIndexNumber int `json:"ParentIndexNumber"` IndexNumber int `json:"IndexNumber"` ProviderIDs map[string]string `json:"ProviderIds"` } func (s *Server) handleMediaReport(w http.ResponseWriter, r *http.Request, sess store.Session) { if s.store == nil { writeError(w, http.StatusServiceUnavailable, "media reporting is unavailable") return } var request mediaReportPayload if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&request); err != nil { writeError(w, http.StatusBadRequest, "invalid media report") return } request.Reason = strings.TrimSpace(request.Reason) request.Comment = strings.TrimSpace(request.Comment) if !mediaReportReasons[request.Reason] || len(request.Comment) > 500 { writeError(w, http.StatusBadRequest, "choose a valid report category and keep the comment under 500 characters") return } itemID := r.PathValue("id") raw, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds,SeriesId,SeriesName,ParentIndexNumber,IndexNumber") if err != nil { s.writeUpstreamError(r.Context(), w, err, "could not identify the media item") return } var item reportItem if err := json.Unmarshal(raw, &item); err != nil { writeError(w, http.StatusBadGateway, "could not read the media item") return } mediaType := strings.ToLower(item.Type) if mediaType != "movie" && mediaType != "episode" { writeError(w, http.StatusBadRequest, "only movies and episodes can be reported") return } report, err := s.store.CreateMediaReport(r.Context(), store.MediaReport{EmbyItemID: itemID, MediaType: mediaType, Title: item.Name, SeriesTitle: item.SeriesName, SeasonNumber: item.ParentIndexNumber, EpisodeNumber: item.IndexNumber, Reason: request.Reason, Comment: request.Comment, ReportedByUserID: sess.EmbyUserID, ReportedByUsername: sess.Username, ReportedByDevice: sess.DeviceName, ReplacementRequested: request.RequestReplacement}) if err != nil { writeError(w, http.StatusConflict, "this media item already has an open report") return } s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeMediaReportCreated, Severity: adminevents.SeverityWarning, Title: "Media issue reported", Summary: fmt.Sprintf("%s reported %s with %s.", sess.Username, reportLabel(report), request.Reason), Link: "/admin/media-reports", Metadata: adminevents.Meta(map[string]any{"reportId": report.ID, "itemId": itemID})}) if request.RequestReplacement { if !s.requestAllowed(r, sess) { s.loggerFor(r.Context()).Warn("replacement not permitted", "report", report.ID, "user", sess.Username) writeJSON(w, http.StatusCreated, report) return } if err := s.requestReplacement(r, report, item); err != nil { s.loggerFor(r.Context()).Warn("media replacement request failed", "report", report.ID, "error", err) s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeReplacementFailed, Severity: adminevents.SeverityWarning, Title: "Replacement search failed", Summary: fmt.Sprintf("%s needs an operator review: %v", reportLabel(report), err), Link: "/admin/media-reports"}) } else { s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeReplacementRequested, Severity: adminevents.SeverityInfo, Title: "Replacement requested", Summary: fmt.Sprintf("A new copy of %s was requested through %s.", reportLabel(report), map[string]string{"movie": "Radarr", "episode": "Sonarr"}[report.MediaType]), Link: "/admin/media-reports"}) } } writeJSON(w, http.StatusCreated, report) } func (s *Server) requestReplacement(r *http.Request, report store.MediaReport, item reportItem) error { ctx := r.Context() if report.MediaType == "movie" { if !s.radarrEnabled(ctx) { return fmt.Errorf("Radarr is unavailable") } tmdb, _ := strconv.Atoi(item.ProviderIDs["Tmdb"]) movies, err := s.radarr.Movies(ctx) if err != nil { return err } for _, movie := range movies { if movie.TMDBID == tmdb && tmdb > 0 { return s.radarr.SearchMovie(ctx, movie.ID) } } return fmt.Errorf("movie is not tracked by Radarr") } if !s.sonarrEnabled(ctx) { return fmt.Errorf("Sonarr is unavailable") } tvdb, _ := strconv.Atoi(item.ProviderIDs["Tvdb"]) series, err := s.sonarr.Series(ctx) if err != nil { return err } for _, show := range series { if show.TVDBID == tvdb && tvdb > 0 { episodes, err := s.sonarr.Episodes(ctx, show.ID) if err != nil { return err } for _, episode := range episodes { if episode.SeasonNumber == item.ParentIndexNumber && episode.EpisodeNumber == item.IndexNumber { return s.sonarr.SearchEpisode(ctx, episode.ID) } } return fmt.Errorf("episode is not tracked by Sonarr") } } return fmt.Errorf("series is not tracked by Sonarr") } func reportLabel(r store.MediaReport) string { if r.MediaType == "episode" { return fmt.Sprintf("%s S%02dE%02d", r.SeriesTitle, r.SeasonNumber, r.EpisodeNumber) } return r.Title } func (s *Server) handleAdminMediaReports(w http.ResponseWriter, r *http.Request) { reports, err := s.store.MediaReports(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not load media reports") return } writeJSON(w, http.StatusOK, map[string]any{"reports": reports}) } func (s *Server) handleAdminMediaReportStatus(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id < 1 { writeError(w, http.StatusBadRequest, "invalid report") return } var request struct { Status string `json:"status"` } if json.NewDecoder(r.Body).Decode(&request) != nil || !map[string]bool{"acknowledged": true, "resolved": true, "dismissed": true}[request.Status] { writeError(w, http.StatusBadRequest, "invalid report status") return } if err := s.store.SetMediaReportStatus(r.Context(), id, request.Status, "administrator"); err != nil { writeError(w, http.StatusInternalServerError, "could not update report") return } if request.Status == "resolved" { s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeMediaReportResolved, Title: "Media report resolved", Summary: fmt.Sprintf("Media report %d was resolved.", id), Link: "/admin/media-reports"}) } w.WriteHeader(http.StatusNoContent) }