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
@@ -0,0 +1,411 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// MembyRatings rides on the item payload so a card can draw its ratings the moment the
|
||||
// row does. The dedicated /v1/items/{id}/ratings endpoint remains for anything the
|
||||
// database has never seen — this field is only ever what was already stored, so
|
||||
// attaching it costs one indexed read for a whole launcher and never an external
|
||||
// request on the request path.
|
||||
const ratingsItemField = "MembyRatings"
|
||||
|
||||
// A launcher is a few hundred cards at most. The cap is a guard against a future row
|
||||
// type asking for a thousand, not a limit anything reaches today.
|
||||
const ratingsAttachItemLimit = 600
|
||||
|
||||
const (
|
||||
// The warmer's pace. MDBList sells a daily allowance rather than a rate, so the
|
||||
// interval only stops a burst of navigation becoming a burst of requests, and the
|
||||
// daily budget is what actually protects the quota.
|
||||
ratingsWarmInterval = 2 * time.Second
|
||||
ratingsWarmDailyBudget = 400
|
||||
// A refused request means the allowance is gone; asking again inside the same hour
|
||||
// only spends the operator's rate limit on nothing.
|
||||
ratingsWarmBackoff = time.Hour
|
||||
ratingsWarmQueue = 512
|
||||
)
|
||||
|
||||
// How long the operator's ratings settings are trusted in memory. Every row, search and
|
||||
// detail response now consults them, and instant search asks once per keystroke — that
|
||||
// is a Postgres round trip per key press for a document changed a few times a year. The
|
||||
// admin write clears this, so a change still takes effect at once.
|
||||
const mdblistSettingsTTL = 30 * time.Second
|
||||
|
||||
type mdblistSettingsCache struct {
|
||||
mu sync.Mutex
|
||||
settings store.MDBListSettings
|
||||
loadedAt time.Time
|
||||
}
|
||||
|
||||
// mdblistSettings reports the ratings configuration, and whether ratings are on at all.
|
||||
func (s *Server) mdblistSettings(ctx context.Context) (store.MDBListSettings, bool) {
|
||||
if s.store == nil {
|
||||
return store.MDBListSettings{}, false
|
||||
}
|
||||
c := &s.mdblistSettingsCache
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
if !c.loadedAt.IsZero() && now.Sub(c.loadedAt) < mdblistSettingsTTL {
|
||||
settings := c.settings
|
||||
c.mu.Unlock()
|
||||
return settings, settings.Enabled && settings.APIKey != "" && len(settings.Sources) > 0
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
settings, err := s.store.MDBListSettings(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("MDBList settings unavailable", "error", err)
|
||||
return store.MDBListSettings{}, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.settings, c.loadedAt = settings, now
|
||||
c.mu.Unlock()
|
||||
return settings, settings.Enabled && settings.APIKey != "" && len(settings.Sources) > 0
|
||||
}
|
||||
|
||||
// forgetMDBListSettings is called by the admin write so an operator's change is live on
|
||||
// the next request rather than at the end of the cache window.
|
||||
func (s *Server) forgetMDBListSettings() {
|
||||
c := &s.mdblistSettingsCache
|
||||
c.mu.Lock()
|
||||
c.loadedAt = time.Time{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// ratingsWarmer renews and fills the durable cache behind the viewer.
|
||||
//
|
||||
// Navigation is what feeds it: every row that reaches a television reports the titles it
|
||||
// could not decorate, and those are fetched once and kept. The queue is bounded and
|
||||
// deliberately lossy — a title dropped now is offered again the next time somebody
|
||||
// scrolls past it, which is a far better failure than a growing backlog of requests
|
||||
// against a daily allowance.
|
||||
type ratingsWarmer struct {
|
||||
once sync.Once
|
||||
queue chan store.RatingKey
|
||||
mu sync.Mutex
|
||||
queued map[store.RatingKey]bool
|
||||
|
||||
spent int
|
||||
windowFrom time.Time
|
||||
blockedTil time.Time
|
||||
}
|
||||
|
||||
// warmRatings offers titles to the background warmer. It never blocks the caller.
|
||||
func (s *Server) warmRatings(keys ...store.RatingKey) {
|
||||
if s.mdblist == nil || s.store == nil || len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
w := &s.ratingsWarm
|
||||
w.once.Do(func() {
|
||||
w.queue = make(chan store.RatingKey, ratingsWarmQueue)
|
||||
w.queued = map[store.RatingKey]bool{}
|
||||
go s.ratingsWarmLoop()
|
||||
})
|
||||
for _, key := range keys {
|
||||
if key.MediaType == "" || key.Provider == "" || key.ProviderID == "" {
|
||||
continue
|
||||
}
|
||||
w.mu.Lock()
|
||||
already := w.queued[key]
|
||||
if !already {
|
||||
w.queued[key] = true
|
||||
}
|
||||
w.mu.Unlock()
|
||||
if already {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case w.queue <- key:
|
||||
default:
|
||||
// Full. Forget it rather than wait: the next row that shows this title will
|
||||
// offer it again, and a blocked handler would be paying for a cache fill.
|
||||
w.mu.Lock()
|
||||
delete(w.queued, key)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ratingsWarmLoop() {
|
||||
w := &s.ratingsWarm
|
||||
for key := range w.queue {
|
||||
w.mu.Lock()
|
||||
delete(w.queued, key)
|
||||
w.mu.Unlock()
|
||||
s.warmOne(key)
|
||||
time.Sleep(ratingsWarmInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) warmOne(key store.RatingKey) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
settings, enabled := s.mdblistSettings(ctx)
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
if !s.claimRatingsBudget(time.Now()) {
|
||||
return
|
||||
}
|
||||
if _, err := s.fetchAndStoreRatings(ctx, settings.APIKey, key); err != nil {
|
||||
var apiErr *mdblist.APIError
|
||||
if errors.As(err, &apiErr) && (apiErr.StatusCode == 429 || apiErr.StatusCode == 402) {
|
||||
s.blockRatingsWarming(time.Now().Add(ratingsWarmBackoff))
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Debug("MDBList warm failed",
|
||||
"component", "ratings", "provider", key.Provider, "id", key.ProviderID, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Debug("MDBList rating stored",
|
||||
"component", "ratings", "provider", key.Provider, "id", key.ProviderID)
|
||||
}
|
||||
}
|
||||
|
||||
// claimRatingsBudget spends one of the day's allowed external requests. The window is
|
||||
// in-process: a restart forgives what was already spent, which is the right way round
|
||||
// for a counter whose only job is to stop a runaway fill.
|
||||
func (s *Server) claimRatingsBudget(now time.Time) bool {
|
||||
w := &s.ratingsWarm
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if now.Before(w.blockedTil) {
|
||||
return false
|
||||
}
|
||||
if w.windowFrom.IsZero() || now.Sub(w.windowFrom) >= 24*time.Hour {
|
||||
w.windowFrom = now
|
||||
w.spent = 0
|
||||
}
|
||||
if w.spent >= ratingsWarmDailyBudget {
|
||||
return false
|
||||
}
|
||||
w.spent++
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) blockRatingsWarming(until time.Time) {
|
||||
w := &s.ratingsWarm
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.blockedTil = until
|
||||
}
|
||||
|
||||
// rememberRatingRef records an item's external identity without delaying the response
|
||||
// that discovered it.
|
||||
func (s *Server) rememberRatingRef(ctx context.Context, itemID string, key store.RatingKey) {
|
||||
if s.store == nil || itemID == "" {
|
||||
return
|
||||
}
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.store.SaveItemRatingRef(writeCtx, itemID, key); err != nil && s.log != nil {
|
||||
s.loggerFor(ctx).Debug("rating reference write failed", "item", itemID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// decorateHomeRatings attaches stored ratings to every collection of items in one pass.
|
||||
//
|
||||
// One pass rather than one per row: the fixed rows and the composed rows share their
|
||||
// backing arrays, so decorating each separately would resolve the same ids repeatedly,
|
||||
// and writing back in place is what keeps both views agreeing.
|
||||
func (s *Server) decorateHomeRatings(ctx context.Context, out *homeResponse) {
|
||||
collections := make([][]json.RawMessage, 0, len(out.Rows)+4)
|
||||
for _, row := range out.Rows {
|
||||
collections = append(collections, row.Items)
|
||||
}
|
||||
collections = append(collections,
|
||||
out.ContinueWatching, out.NextUp, out.Favorites, out.LatestMovies)
|
||||
s.decorateItemRatings(ctx, collections...)
|
||||
}
|
||||
|
||||
// decorateItemRatings rewrites each item in place with whatever the database already
|
||||
// holds for it, and offers the rest to the warmer.
|
||||
func (s *Server) decorateItemRatings(ctx context.Context, collections ...[]json.RawMessage) {
|
||||
settings, enabled := s.mdblistSettings(ctx)
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
ids := itemIDsIn(collections, ratingsAttachItemLimit)
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
keys := s.ratingKeysForItems(ctx, ids)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
stored, err := s.store.MediaRatingsBatch(ctx, distinctRatingKeys(keys))
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("stored ratings read failed", "error", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
byItem := make(map[string][]movieRating, len(keys))
|
||||
warm := make([]store.RatingKey, 0)
|
||||
warmed := make(map[store.RatingKey]bool, len(keys))
|
||||
for itemID, key := range keys {
|
||||
entry, ok := stored[key]
|
||||
var ratings []mdblist.Rating
|
||||
if ok {
|
||||
if json.Unmarshal(entry.Ratings, &ratings) != nil {
|
||||
ratings = nil
|
||||
}
|
||||
byItem[itemID] = selectedMovieRatings(settings.Sources, ratings)
|
||||
}
|
||||
if (!ok || ratingsNeedRefresh(ratings, entry.FetchedAt, now)) && !warmed[key] {
|
||||
warmed[key] = true
|
||||
warm = append(warm, key)
|
||||
}
|
||||
}
|
||||
for _, items := range collections {
|
||||
for index, raw := range items {
|
||||
id := itemIDOf(raw)
|
||||
ratings, ok := byItem[id]
|
||||
if !ok || len(ratings) == 0 {
|
||||
continue
|
||||
}
|
||||
items[index] = injectItemRatings(raw, ratings)
|
||||
}
|
||||
}
|
||||
// Only what the household actually looked at is warmed, which is what keeps a large
|
||||
// library from being imported into MDBList's quota all at once.
|
||||
s.warmRatings(warm...)
|
||||
}
|
||||
|
||||
// decorateRowRatings is the row-shaped entry point used outside Home.
|
||||
func (s *Server) decorateRowRatings(ctx context.Context, rows []recommend.Row) {
|
||||
collections := make([][]json.RawMessage, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
collections = append(collections, row.Items)
|
||||
}
|
||||
s.decorateItemRatings(ctx, collections...)
|
||||
}
|
||||
|
||||
// ratingKeysForItems resolves Emby ids to external titles, preferring the index built by
|
||||
// navigation and falling back to the imported library.
|
||||
func (s *Server) ratingKeysForItems(ctx context.Context, ids []string) map[string]store.RatingKey {
|
||||
keys, err := s.store.ItemRatingRefs(ctx, ids)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("rating references read failed", "error", err)
|
||||
keys = map[string]store.RatingKey{}
|
||||
}
|
||||
missing := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, ok := keys[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return keys
|
||||
}
|
||||
refs, err := s.store.LibraryProviderIDs(ctx, missing)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("library provider ids read failed", "error", err)
|
||||
return keys
|
||||
}
|
||||
for id, ref := range refs {
|
||||
if key, ok := ratingKeyFor(ref.Type, ref.ProviderIDs); ok {
|
||||
keys[id] = key
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// ratingKeyFor applies the same rule the live lookup does: MDBList rates films and
|
||||
// shows, an episode is rated as its series, and tmdb wins over imdb.
|
||||
func ratingKeyFor(itemType string, ids map[string]string) (store.RatingKey, bool) {
|
||||
var mediaType string
|
||||
switch {
|
||||
case strings.EqualFold(itemType, "Movie"):
|
||||
mediaType = "movie"
|
||||
case strings.EqualFold(itemType, "Series"), strings.EqualFold(itemType, "Episode"):
|
||||
mediaType = "show"
|
||||
default:
|
||||
return store.RatingKey{}, false
|
||||
}
|
||||
provider, providerID := movieProvider(ids)
|
||||
if providerID == "" {
|
||||
return store.RatingKey{}, false
|
||||
}
|
||||
return store.RatingKey{MediaType: mediaType, Provider: provider, ProviderID: providerID}, true
|
||||
}
|
||||
|
||||
func distinctRatingKeys(keys map[string]store.RatingKey) []store.RatingKey {
|
||||
seen := make(map[store.RatingKey]bool, len(keys))
|
||||
out := make([]store.RatingKey, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectItemRatings adds the ratings field to one item payload. Emby's JSON is otherwise
|
||||
// forwarded verbatim, so this decodes into raw members and re-encodes rather than
|
||||
// through any struct: a field this build does not know about must survive the round trip.
|
||||
func injectItemRatings(raw json.RawMessage, ratings []movieRating) json.RawMessage {
|
||||
if len(ratings) == 0 {
|
||||
return raw
|
||||
}
|
||||
var members map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &members) != nil || members == nil {
|
||||
return raw
|
||||
}
|
||||
encoded, err := json.Marshal(ratings)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
members[ratingsItemField] = encoded
|
||||
out, err := json.Marshal(members)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func itemIDOf(raw json.RawMessage) string {
|
||||
var item struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &item) != nil {
|
||||
return ""
|
||||
}
|
||||
return item.ID
|
||||
}
|
||||
|
||||
func itemIDsIn(collections [][]json.RawMessage, limit int) []string {
|
||||
seen := make(map[string]bool)
|
||||
ids := make([]string, 0, limit)
|
||||
for _, items := range collections {
|
||||
for _, raw := range items {
|
||||
id := itemIDOf(raw)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
if len(ids) >= limit {
|
||||
return ids
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
Reference in New Issue
Block a user