Add optional MDBList movie ratings
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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"), ".")
|
||||
}
|
||||
Reference in New Issue
Block a user