169 lines
7.0 KiB
Go
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)
|
|
}
|