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 ( // The Redis entry is only the faster first hop. Postgres is the durable cache and is // what decides whether an external request happens at all. mdblistRatingsTTL = 24 * time.Hour // How old a stored response may be before it is refreshed. Critics' scores move // slowly and the operator's quota is a daily allowance, so a stored value is always // served immediately and any refresh happens behind the request. ratingsRefreshInterval = 30 * 24 * time.Hour // A title MDBList had nothing for is retried sooner: a film released this week // genuinely gains scores, and the empty answer is cheap to have been wrong about. ratingsEmptyRefreshInterval = 3 * 24 * time.Hour ) // ratingsNeedRefresh decides whether a stored response should be renewed. It never // decides whether one is *served* — a stored value, however old, beats an empty strip. func ratingsNeedRefresh(ratings []mdblist.Rating, fetchedAt, now time.Time) bool { if fetchedAt.IsZero() { return true } age := now.Sub(fetchedAt) if len(ratings) == 0 { return age >= ratingsEmptyRefreshInterval } return age >= ratingsRefreshInterval } 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}, "anilist": {Name: "AniList", Scale: "%", Maximum: 100}, "anidb": {Name: "AniDB", Scale: "/10", Maximum: 10}, "kitsu": {Name: "Kitsu", Scale: "%", Maximum: 100}, "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", "anilist": "anilist", "anidb": "anidb", "kitsu": "kitsu", "score": "score", "score_average": "score_average", "scoreaverage": "score_average", } type ratingsEmbyItem struct { Type string `json:"Type"` SeriesID string `json:"SeriesId"` 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.mdblist == nil { writeJSON(w, http.StatusOK, empty) return } settings, enabled := s.mdblistSettings(r.Context()) if !enabled { writeJSON(w, http.StatusOK, empty) return } rawItem, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds,SeriesId") if err != nil { s.logMDBListFailure(r.Context(), "movie identifiers unavailable", itemID, err) writeJSON(w, http.StatusOK, empty) return } var item ratingsEmbyItem if json.Unmarshal(rawItem, &item) != nil { writeJSON(w, http.StatusOK, empty) return } mediaType := "movie" if strings.EqualFold(item.Type, "Series") { mediaType = "show" } else if strings.EqualFold(item.Type, "Episode") && item.SeriesID != "" { seriesRaw, seriesErr := s.emby.Item(r.Context(), credentials(sess), item.SeriesID, "ProviderIds") if seriesErr != nil || json.Unmarshal(seriesRaw, &item) != nil { writeJSON(w, http.StatusOK, empty) return } mediaType = "show" } else if !strings.EqualFold(item.Type, "Movie") { writeJSON(w, http.StatusOK, empty) return } provider, providerID := movieProvider(item.ProviderIDs) if providerID == "" { writeJSON(w, http.StatusOK, empty) return } key := store.RatingKey{MediaType: mediaType, Provider: provider, ProviderID: providerID} // Remember what this item is called externally. A row can then attach its rating // from the database without spending an Emby request per card, and the index fills // in as the household browses rather than waiting on a full library import. s.rememberRatingRef(r.Context(), itemID, key) ratings, err := s.loadMDBListRatings(r.Context(), settings.APIKey, key) if err != nil { s.logMDBListFailure(r.Context(), "ratings unavailable", itemID, err) writeJSON(w, http.StatusOK, empty) return } writeJSON(w, http.StatusOK, movieRatingsResponse{ Ratings: selectedMovieRatings(settings.Sources, ratings), }) } // loadMDBListRatings answers from the durable cache whenever it holds anything at all. // An external request happens only for a title never seen before; an ageing one is // renewed behind the viewer by the warmer, so browsing never waits on MDBList and a // household's daily quota is spent on new titles rather than on repeat visits. func (s *Server) loadMDBListRatings( ctx context.Context, apiKey string, key store.RatingKey, ) ([]mdblist.Rating, error) { cacheKey := ratingsCacheKey(key) if ratings, ok := s.cachedMDBListRatings(ctx, cacheKey); ok { return ratings, nil } stored, fetchedAt, hasPersistent := s.persistedMDBListRatings(ctx, key) if hasPersistent { s.cacheMDBListRatings(ctx, cacheKey, stored) if ratingsNeedRefresh(stored, fetchedAt, time.Now()) { s.warmRatings(key) } return stored, 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, cacheKey); ok { return ratings, nil } // Recheck Postgres under the lock: another request may have populated it while this // request waited. Postgres is the durable cache; Redis is only the faster first hop. if stored, _, hasPersistent = s.persistedMDBListRatings(ctx, key); hasPersistent { s.cacheMDBListRatings(ctx, cacheKey, stored) return stored, nil } return s.fetchAndStoreRatings(ctx, apiKey, key) } // fetchAndStoreRatings is the only place an external request is made, so the durable // write and the hot cache can never disagree about what was fetched. func (s *Server) fetchAndStoreRatings( ctx context.Context, apiKey string, key store.RatingKey, ) ([]mdblist.Rating, error) { ratings, err := s.mdblist.Media(ctx, apiKey, key.Provider, key.ProviderID, key.MediaType) if err != nil { return nil, err } if ratings == nil { ratings = []mdblist.Rating{} } raw, marshalErr := json.Marshal(ratings) if marshalErr != nil { return ratings, nil } if saveErr := s.store.SaveMediaRatings( ctx, key.MediaType, key.Provider, key.ProviderID, raw, ); saveErr != nil && s.log != nil { s.loggerFor(ctx).Warn("MDBList rating database write failed", "error", saveErr) } s.cacheMDBListRatings(ctx, ratingsCacheKey(key), ratings) return ratings, nil } func ratingsCacheKey(key store.RatingKey) string { return "mdblist:ratings:v2:" + key.MediaType + ":" + key.Provider + ":" + key.ProviderID } func (s *Server) cachedMDBListRatings(ctx context.Context, key string) ([]mdblist.Rating, bool) { if s.cache == nil { return nil, false } 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) persistedMDBListRatings( ctx context.Context, key store.RatingKey, ) ([]mdblist.Rating, time.Time, bool) { raw, fetchedAt, err := s.store.MediaRatings(ctx, key.MediaType, key.Provider, key.ProviderID) if err != nil { if err != store.ErrMediaRatingsNotFound && s.log != nil { s.loggerFor(ctx).Warn("MDBList rating database read failed", "error", err) } return nil, time.Time{}, false } var ratings []mdblist.Rating if json.Unmarshal(raw, &ratings) != nil { return nil, time.Time{}, false } if ratings == nil { ratings = []mdblist.Rating{} } return ratings, fetchedAt, true } func (s *Server) cacheMDBListRatings(ctx context.Context, key string, ratings []mdblist.Rating) { if s.cache == nil { return } raw, err := json.Marshal(ratings) if err != nil { return } if err := s.cache.Set(ctx, key, raw, mdblistRatingsTTL); err != nil && s.log != nil { s.loggerFor(ctx).Warn("MDBList rating cache write failed", "error", err) } } func (s *Server) logMDBListFailure(ctx context.Context, message, itemID string, err error) { if s.log != nil { s.loggerFor(ctx).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, source), Scale: source.Scale, }) } if result == nil { return []movieRating{} } return result } // formatRatingScore writes a score the way its own scale is read. A fractional scale // always shows its decimal — IMDb 7 is written "7.0", because "7" beside an "8.2" reads // as a different kind of number rather than the same one that happens to be round — and // a scale measured in whole points (percentages, /100) never grows one. func formatRatingScore(value float64, source ratingSource) string { if source.Maximum <= 10 { return strconv.FormatFloat(value, 'f', 1, 64) } return strconv.FormatFloat(value, 'f', 0, 64) }