App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
+151
-26
@@ -12,7 +12,33 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
const mdblistRatingsTTL = 24 * time.Hour
|
||||
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"`
|
||||
@@ -41,6 +67,9 @@ var movieRatingSources = map[string]ratingSource{
|
||||
"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,
|
||||
@@ -54,11 +83,13 @@ var movieRatingAliases = map[string]string{
|
||||
"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"`
|
||||
}
|
||||
|
||||
@@ -72,27 +103,38 @@ func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess
|
||||
writeJSON(w, http.StatusOK, empty)
|
||||
return
|
||||
}
|
||||
if s.store == nil || s.emby == nil || s.cache == nil || s.mdblist == nil {
|
||||
if s.store == nil || s.emby == 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)
|
||||
}
|
||||
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")
|
||||
rawItem, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds,SeriesId")
|
||||
if err != nil {
|
||||
s.logMDBListFailure("movie identifiers unavailable", itemID, err)
|
||||
s.logMDBListFailure(r.Context(), "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") {
|
||||
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
|
||||
}
|
||||
@@ -101,10 +143,15 @@ func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess
|
||||
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, provider, providerID)
|
||||
ratings, err := s.loadMDBListRatings(r.Context(), settings.APIKey, key)
|
||||
if err != nil {
|
||||
s.logMDBListFailure("ratings unavailable", itemID, err)
|
||||
s.logMDBListFailure(r.Context(), "ratings unavailable", itemID, err)
|
||||
writeJSON(w, http.StatusOK, empty)
|
||||
return
|
||||
}
|
||||
@@ -113,36 +160,74 @@ func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess
|
||||
})
|
||||
}
|
||||
|
||||
// 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, provider, providerID string,
|
||||
ctx context.Context, apiKey string, key store.RatingKey,
|
||||
) ([]mdblist.Rating, error) {
|
||||
key := "mdblist:movie-ratings:v1:" + provider + ":" + providerID
|
||||
if ratings, ok := s.cachedMDBListRatings(ctx, key); ok {
|
||||
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, key); ok {
|
||||
if ratings, ok := s.cachedMDBListRatings(ctx, cacheKey); ok {
|
||||
return ratings, nil
|
||||
}
|
||||
ratings, err := s.mdblist.Movie(ctx, apiKey, provider, providerID)
|
||||
// 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{}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
@@ -157,9 +242,42 @@ func (s *Server) cachedMDBListRatings(ctx context.Context, key string) ([]mdblis
|
||||
return ratings, true
|
||||
}
|
||||
|
||||
func (s *Server) logMDBListFailure(message, itemID string, err error) {
|
||||
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.log.Debug("MDBList "+message, "item", itemID, "error", err)
|
||||
s.loggerFor(ctx).Debug("MDBList "+message, "item", itemID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +314,7 @@ func selectedMovieRatings(selected []string, available []mdblist.Rating) []movie
|
||||
}
|
||||
seen[id] = true
|
||||
result = append(result, movieRating{
|
||||
Source: id, Name: source.Name, Score: formatRatingScore(value), Scale: source.Scale,
|
||||
Source: id, Name: source.Name, Score: formatRatingScore(value, source), Scale: source.Scale,
|
||||
})
|
||||
}
|
||||
if result == nil {
|
||||
@@ -205,6 +323,13 @@ func selectedMovieRatings(selected []string, available []mdblist.Rating) []movie
|
||||
return result
|
||||
}
|
||||
|
||||
func formatRatingScore(value float64) string {
|
||||
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 2, 64), "0"), ".")
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user