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
@@ -14,7 +14,9 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
const sonarrCalendarCachePrefix = "sonarr:calendar:v2:"
|
||||
// Bump when the authored row contract changes so an older ordering cannot survive a
|
||||
// deployment until the previous daily cache expires.
|
||||
const sonarrCalendarCachePrefix = "sonarr:calendar:v4:"
|
||||
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
|
||||
const sonarrScheduleDays = 5
|
||||
|
||||
@@ -41,7 +43,7 @@ func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.S
|
||||
schedule, err := s.sonarrPrerollSchedule(r.Context())
|
||||
if err != nil {
|
||||
// Pre-roll is decorative and must never become a playback dependency.
|
||||
s.log.Warn("preroll schedule unavailable", "error", err)
|
||||
s.loggerFor(r.Context()).Warn("preroll schedule unavailable", "error", err)
|
||||
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
|
||||
return
|
||||
}
|
||||
@@ -79,7 +81,7 @@ func (s *Server) sonarrPrerollSchedule(ctx context.Context) (prerollScheduleResp
|
||||
schedule := buildPrerollSchedule(episodes, now, location)
|
||||
if raw, err := json.Marshal(schedule); err == nil {
|
||||
if cacheErr := s.cache.Set(ctx, key, raw, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.log.Warn("preroll schedule cache write failed", "error", cacheErr)
|
||||
s.loggerFor(ctx).Warn("preroll schedule cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return schedule, nil
|
||||
@@ -186,7 +188,70 @@ type sonarrScheduleItem struct {
|
||||
MembyAirLabel string `json:"MembyAirLabel"`
|
||||
MembyAvailability string `json:"MembyAvailability"`
|
||||
MembyAvailabilityText string `json:"MembyAvailabilityText"`
|
||||
MembyPlayable bool `json:"MembyPlayable"`
|
||||
// Sonarr's lifecycle for the *show*, distinct from this episode's availability: one
|
||||
// says whether more episodes are coming at all, the other whether this one is here yet.
|
||||
MembyLifecycle string `json:"MembyLifecycle,omitempty"`
|
||||
MembyLifecycleText string `json:"MembyLifecycleText,omitempty"`
|
||||
MembyPlayable bool `json:"MembyPlayable"`
|
||||
// The Emby series this episode belongs to, when the library holds it. It is what lets
|
||||
// the card open the show's own page; a show Sonarr follows but Emby has never imported
|
||||
// simply carries none, and the card stays informational as it always was.
|
||||
MembySeriesItemID string `json:"MembySeriesItemId,omitempty"`
|
||||
}
|
||||
|
||||
// seriesIndex resolves a Sonarr series title and year onto an Emby item id. It is a map
|
||||
// rather than a store call per episode: one row can carry a dozen episodes of the same
|
||||
// show, and the answer is the same for all of them.
|
||||
type seriesIndex map[string]string
|
||||
|
||||
// embySeriesIndex builds the lookup for one row build. A failure is not fatal — the row
|
||||
// is about what is *about* to air, and losing the link only costs the card its page.
|
||||
func (s *Server) embySeriesIndex(ctx context.Context) seriesIndex {
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
refs, err := s.store.SeriesRefs(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("emby series index unavailable for schedule row", "error", err)
|
||||
return nil
|
||||
}
|
||||
index := make(seriesIndex, len(refs)*2)
|
||||
for _, ref := range refs {
|
||||
key := normalizedShowTitle(ref.Name)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
// The year-qualified key is written first and never overwritten, so a remake
|
||||
// cannot claim the original's page when both are in the library.
|
||||
if ref.Year > 0 {
|
||||
if _, seen := index[seriesIndexKey(key, ref.Year)]; !seen {
|
||||
index[seriesIndexKey(key, ref.Year)] = ref.ID
|
||||
}
|
||||
}
|
||||
if _, seen := index[key]; !seen {
|
||||
index[key] = ref.ID
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func seriesIndexKey(normalizedTitle string, year int) string {
|
||||
return fmt.Sprintf("%s|%d", normalizedTitle, year)
|
||||
}
|
||||
|
||||
// lookup prefers the title/year pair and falls back to the title alone, because Sonarr
|
||||
// and Emby disagree about a show's year more often than they disagree about its name.
|
||||
func (index seriesIndex) lookup(title string, year int) string {
|
||||
key := normalizedShowTitle(title)
|
||||
if key == "" || len(index) == 0 {
|
||||
return ""
|
||||
}
|
||||
if year > 0 {
|
||||
if id, ok := index[seriesIndexKey(key, year)]; ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return index[key]
|
||||
}
|
||||
|
||||
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
|
||||
@@ -221,14 +286,14 @@ func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := buildSonarrRow(episodes, now, location)
|
||||
row, err := buildSonarrRow(episodes, now, location, s.embySeriesIndex(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(row)
|
||||
if err == nil {
|
||||
if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.log.Warn("sonarr calendar cache write failed", "error", cacheErr)
|
||||
s.loggerFor(ctx).Warn("sonarr calendar cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return row, nil
|
||||
@@ -246,7 +311,12 @@ func (s *Server) cachedSonarrRow(ctx context.Context, key string) *recommend.Row
|
||||
return &row
|
||||
}
|
||||
|
||||
func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Location) (*recommend.Row, error) {
|
||||
func buildSonarrRow(
|
||||
episodes []sonarr.Episode,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
series seriesIndex,
|
||||
) (*recommend.Row, error) {
|
||||
sort.SliceStable(episodes, func(i, j int) bool {
|
||||
if episodes[i].AirDateUTC == nil {
|
||||
return false
|
||||
@@ -268,7 +338,7 @@ func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Loc
|
||||
if localAirTime.Before(dayStart) || !localAirTime.Before(windowEnd) {
|
||||
continue
|
||||
}
|
||||
item := toSonarrScheduleItem(episode, now, location)
|
||||
item := toSonarrScheduleItem(episode, now, location, series)
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -283,7 +353,12 @@ func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Loc
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.Location) sonarrScheduleItem {
|
||||
func toSonarrScheduleItem(
|
||||
episode sonarr.Episode,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
series seriesIndex,
|
||||
) sonarrScheduleItem {
|
||||
seriesID := episode.SeriesID
|
||||
if episode.Series.ID > 0 {
|
||||
seriesID = episode.Series.ID
|
||||
@@ -303,6 +378,9 @@ func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.
|
||||
MembyEpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
|
||||
MembyPlayable: false,
|
||||
}
|
||||
item.MembySeriesItemID = series.lookup(episode.Series.Title, episode.Series.Year)
|
||||
lifecycle := seriesLifecycleTag(episode.Series.Status)
|
||||
item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label
|
||||
if hasCover(episode.Series.Images, "poster") {
|
||||
item.ImageTags["Primary"] = "sonarr"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user