package api import ( "context" "encoding/json" "net/http" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/mdblist" "github.com/ponzischeme89/memby/server/internal/store" ) const mdblistRatingsTTL = 24 * time.Hour type movieRating struct { Source string `json:"source"` Name string `json:"name"` Score string `json:"score"` Scale string `json:"scale"` } type movieRatingsResponse struct { Ratings []movieRating `json:"ratings"` } type ratingSource struct { Name string Scale string Maximum float64 } var movieRatingSources = map[string]ratingSource{ "imdb": {Name: "IMDb", Scale: "/10", Maximum: 10}, "tomatoes": {Name: "Rotten Tomatoes", Scale: "%", Maximum: 100}, "audience": {Name: "Rotten Tomatoes Audience", Scale: "%", Maximum: 100}, "metacritic": {Name: "Metacritic", Scale: "/100", Maximum: 100}, "letterboxd": {Name: "Letterboxd", Scale: "/5", Maximum: 5}, "rogerebert": {Name: "Roger Ebert", Scale: "/4", Maximum: 4}, "tmdb": {Name: "TMDb", Scale: "/10", Maximum: 10}, "trakt": {Name: "Trakt", Scale: "%", Maximum: 100}, "mal": {Name: "MyAnimeList", Scale: "/10", Maximum: 10}, "score": {Name: "MDBList Score", Scale: "/100", Maximum: 100}, "score_average": { Name: "MDBList Average", Scale: "/100", Maximum: 100, }, } var movieRatingAliases = map[string]string{ "imdb": "imdb", "tomatoes": "tomatoes", "rottentomatoes": "tomatoes", "rtomatoes": "tomatoes", "rttomatoes": "tomatoes", "audience": "audience", "tomatoesaudience": "audience", "rtaudience": "audience", "metacritic": "metacritic", "letterboxd": "letterboxd", "rogerebert": "rogerebert", "roger_ebert": "rogerebert", "tmdb": "tmdb", "trakt": "trakt", "mal": "mal", "myanimelist": "mal", "score": "score", "score_average": "score_average", "scoreaverage": "score_average", } type ratingsEmbyItem struct { Type string `json:"Type"` ProviderIDs map[string]string `json:"ProviderIds"` } // handleMovieRatings is deliberately separate from handleItem. External ratings arrive // after the essential metadata, and every failure path returns an empty successful // response so MDBList can never prevent a detail page from opening. func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess store.Session) { empty := movieRatingsResponse{Ratings: []movieRating{}} itemID := strings.TrimSpace(r.PathValue("id")) if itemID == "" { writeJSON(w, http.StatusOK, empty) return } if s.store == nil || s.emby == nil || s.cache == nil || s.mdblist == nil { writeJSON(w, http.StatusOK, empty) return } settings, err := s.store.MDBListSettings(r.Context()) if err != nil || !settings.Enabled || settings.APIKey == "" || len(settings.Sources) == 0 { if err != nil && s.log != nil { s.log.Warn("MDBList settings unavailable", "error", err) } writeJSON(w, http.StatusOK, empty) return } rawItem, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds") if err != nil { s.logMDBListFailure("movie identifiers unavailable", itemID, err) writeJSON(w, http.StatusOK, empty) return } var item ratingsEmbyItem if json.Unmarshal(rawItem, &item) != nil || !strings.EqualFold(item.Type, "Movie") { writeJSON(w, http.StatusOK, empty) return } provider, providerID := movieProvider(item.ProviderIDs) if providerID == "" { writeJSON(w, http.StatusOK, empty) return } ratings, err := s.loadMDBListRatings(r.Context(), settings.APIKey, provider, providerID) if err != nil { s.logMDBListFailure("ratings unavailable", itemID, err) writeJSON(w, http.StatusOK, empty) return } writeJSON(w, http.StatusOK, movieRatingsResponse{ Ratings: selectedMovieRatings(settings.Sources, ratings), }) } func (s *Server) loadMDBListRatings( ctx context.Context, apiKey, provider, providerID string, ) ([]mdblist.Rating, error) { key := "mdblist:movie-ratings:v1:" + provider + ":" + providerID if ratings, ok := s.cachedMDBListRatings(ctx, key); ok { return ratings, nil } // A viewer can move focus rapidly and open the same title before the first request // finishes. Double-checking under the lock keeps that from spending quota twice. s.mdblistMu.Lock() defer s.mdblistMu.Unlock() if ratings, ok := s.cachedMDBListRatings(ctx, key); ok { return ratings, nil } ratings, err := s.mdblist.Movie(ctx, apiKey, provider, providerID) if err != nil { return nil, err } if ratings == nil { ratings = []mdblist.Rating{} } if raw, marshalErr := json.Marshal(ratings); marshalErr == nil { if cacheErr := s.cache.Set(ctx, key, raw, mdblistRatingsTTL); cacheErr != nil && s.log != nil { s.log.Warn("MDBList rating cache write failed", "error", cacheErr) } } return ratings, nil } func (s *Server) cachedMDBListRatings(ctx context.Context, key string) ([]mdblist.Rating, bool) { raw, err := s.cache.Get(ctx, key) if err != nil { return nil, false } var ratings []mdblist.Rating if json.Unmarshal(raw, &ratings) != nil { return nil, false } if ratings == nil { ratings = []mdblist.Rating{} } return ratings, true } func (s *Server) logMDBListFailure(message, itemID string, err error) { if s.log != nil { s.log.Debug("MDBList "+message, "item", itemID, "error", err) } } func movieProvider(ids map[string]string) (string, string) { if id := strings.TrimSpace(providerID(ids, "tmdb")); id != "" { return "tmdb", id } if id := strings.TrimSpace(providerID(ids, "imdb")); id != "" { return "imdb", id } return "", "" } func selectedMovieRatings(selected []string, available []mdblist.Rating) []movieRating { values := make(map[string]float64, len(available)) for _, rating := range available { canonical := movieRatingAliases[strings.ToLower(strings.TrimSpace(rating.Source))] source, known := movieRatingSources[canonical] if !known || rating.Value <= 0 || rating.Value > source.Maximum { continue } if _, exists := values[canonical]; !exists { values[canonical] = rating.Value } } result := make([]movieRating, 0, len(selected)) seen := map[string]bool{} for _, id := range selected { id = strings.ToLower(strings.TrimSpace(id)) source, known := movieRatingSources[id] value, available := values[id] if !known || !available || seen[id] { continue } seen[id] = true result = append(result, movieRating{ Source: id, Name: source.Name, Score: formatRatingScore(value), Scale: source.Scale, }) } if result == nil { return []movieRating{} } return result } func formatRatingScore(value float64) string { return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 2, 64), "0"), ".") }