Files
memby/server/internal/api/media_reports.go
2026-08-14 13:32:14 +12:00

169 lines
7.0 KiB
Go

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)
}