0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
+3
View File
@@ -40,6 +40,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount))
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
mux.Handle("PUT /admin/api/accounts/{userID}/notifications", s.adminAuth(s.handleAdminNotificationPreferences))
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
s.adminAuth(s.handleAdminRestorePreferences))
@@ -58,6 +59,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus))
mux.Handle("POST /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStart))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
+41 -2
View File
@@ -43,7 +43,8 @@ type adminMembyAccount struct {
// of it. Saved is false for someone who has never synced — the values shown are then
// the defaults, and saying so is the difference between "chose this" and "has not
// chosen anything".
Settings adminAccountSettings `json:"settings"`
Settings adminAccountSettings `json:"settings"`
Notifications store.NotificationPreferences `json:"notifications"`
// Themes is the ids this person may choose between, and an empty array means every
// selectable theme rather than none — the same permissive reading the store and
// themeAllowed take. The console renders that as every box ticked, which is what an
@@ -100,6 +101,12 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
themes = map[string][]string{}
}
notifications, err := s.store.AllNotificationPreferences(r.Context())
if err != nil {
s.loggerFor(r.Context()).Warn("account notification preferences read failed", "error", err)
notifications = map[string]store.NotificationPreferences{}
}
result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts {
pref := preferences[account.ID]
@@ -125,10 +132,15 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
if !stored.UpdatedAt.IsZero() {
accountSettings.UpdatedAt = stored.UpdatedAt
}
notificationPrefs, savedNotifications := notifications[account.ID]
if !savedNotifications {
notificationPrefs = store.DefaultNotificationPreferences()
}
result = append(result, adminMembyAccount{
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
Themes: nonNilStrings(themes[account.ID]),
Themes: nonNilStrings(themes[account.ID]),
Notifications: notificationPrefs,
Recommendations: adminOnboardingPreferences{
Completed: pref.Completed, Prompted: pref.Prompted,
Updated: len(account.RecommendationPreferences) > 2,
@@ -153,6 +165,33 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
})
}
// handleAdminNotificationPreferences changes what one person is told without requiring
// a television or an app release. The loaded value is decoded in place so a console from
// an older gateway generation cannot accidentally turn off fields it does not know.
func (s *Server) handleAdminNotificationPreferences(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
prefs, err := s.notificationPreferencesFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load notification settings")
return
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&prefs); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if err := s.saveNotificationPreferences(r.Context(), userID, prefs); err != nil {
s.loggerFor(r.Context()).Error("admin notification preferences save failed", "user", userID, "error", err)
writeError(w, http.StatusInternalServerError, "could not save notification settings")
return
}
s.loggerFor(r.Context()).Info("notification settings saved for viewer", "user", userID)
writeJSON(w, http.StatusOK, prefs)
}
func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
+14 -3
View File
@@ -27,6 +27,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/credits"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/foryou"
"github.com/ponzischeme89/memby/server/internal/integrations"
@@ -51,6 +52,12 @@ type Server struct {
radarr *radarr.Client
bazarr *bazarr.Client
mdblist *mdblist.Client
// credits discovers where an episode's closing credits begin, for the small number of
// episodes the household is about to watch. Nil when the subsystem is switched off, and
// every call site tolerates that — a missing marker simply means no Skip Credits button,
// which is the same state a library with no chapter markers is already in.
credits *credits.Service
creditsLoad *credits.PlaybackLoad
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
@@ -112,6 +119,8 @@ type Deps struct {
Radarr *radarr.Client
Bazarr *bazarr.Client
MDBList *mdblist.Client
Credits *credits.Service
CreditsLoad *credits.PlaybackLoad
Syncer syncerHandle
Log *slog.Logger
Events *serverlogging.Buffer
@@ -133,6 +142,8 @@ func New(cfg config.Config, deps Deps) *Server {
radarr: deps.Radarr,
bazarr: deps.Bazarr,
mdblist: deps.MDBList,
credits: deps.Credits,
creditsLoad: deps.CreditsLoad,
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
@@ -271,9 +282,9 @@ func (s *Server) Routes() http.Handler {
// process. Keep it outside authentication and maintenance so offline/start-up fallback
// never depends on a session being available.
mux.HandleFunc("GET /v1/config", s.handleRemoteConfig)
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
// whether the server requires an update. A valid session enriches only its log context.
// Update compatibility is app-scoped; a signed-in viewer may only mute the optional
// prompt. Keep this outside authentication and maintenance so a fresh install, a
// signed-out TV, and especially a retired build can still learn what it must do.
mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate)))
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
// status even while every normal /v1 operation is deliberately unavailable.
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"context"
"fmt"
"time"
"github.com/ponzischeme89/memby/server/internal/scheduler"
)
// The gateway's side of credits discovery: where a discovered marker is read, and where the
// two live signals that drive it are picked up.
//
// Both signals are things the gateway was already being told. Nothing new is reported by a
// television and nothing new is written on the playback path — the subsystem is fed entirely
// by reports that existed before it did.
// discoveredCredits reads a stored marker for an item.
//
// One indexed lookup on the playback path, and it only happens for a title Emby had no
// chapter marker for. A failure is silence: this is an optional convenience on a film that is
// already playing, and there is nothing a viewer could do about an error.
func (s *Server) discoveredCredits(ctx context.Context, itemID string) (int64, bool) {
if s.credits == nil {
return 0, false
}
marker, found, err := s.credits.Marker(ctx, itemID)
if err != nil {
s.loggerFor(ctx).Debug("discovered credits unavailable", "item_id", itemID, "error", err)
return 0, false
}
if !found || marker.CreditsStartMs <= 0 {
return 0, false
}
return marker.CreditsStartMs, true
}
// noteCreditsPlayback feeds the two live signals from one playback report.
//
// The load gauge and the scan trigger are updated from the same call because they are two
// readings of one event, and splitting them across call sites is how the two come to
// disagree about what is playing.
//
// A started or progressing playback is the strongest evidence there is that an episode
// matters, but it does not scan anything yet: NotePlayback holds the candidate for its
// settling delay first, so an episode somebody opened and abandoned costs nothing.
func (s *Server) noteCreditsPlayback(ctx context.Context, phase, itemID, sessionKey string) {
if itemID == "" {
return
}
switch phase {
case "started":
s.creditsLoad.Playing(sessionKey)
if s.credits != nil && s.featureEnabled(ctx, featureEndCredits) {
s.credits.NotePlayback(ctx, itemID)
}
case "progress":
// Progress refreshes the gauge's timestamp. Without it a television that stopped
// reporting — a crash, a power cut — would hold the gauge busy for ever and
// speculative scanning would never run again.
s.creditsLoad.Playing(sessionKey)
case "stopped":
s.creditsLoad.Stopped(sessionKey)
// A playback that ended before its delay elapsed is exactly the case the delay
// exists for: somebody looked at the episode and changed their mind, and nothing
// should be read from disk on their account.
if s.credits != nil {
s.credits.AbandonPlayback(itemID)
}
}
}
// RegisterCreditsTasks declares the demand refresh, so its interval is the operator's to
// change and its last run is visible in the console beside every other background job.
//
// Refreshing on a schedule rather than continuously is the point: viewing behaviour changes
// over evenings, not seconds, and polling Tracearr any harder would cost more than the
// scanning it directs. Live playback is immediate and does not come through here.
func (s *Server) RegisterCreditsTasks(sched *scheduler.Scheduler) {
if sched == nil || s.credits == nil {
return
}
sched.Register(scheduler.Task{
ID: "credits-candidates",
Name: "Credits candidate refresh",
Group: "Library",
Description: "Rebuilds the credits-detection queue from what the household has " +
"recently been watching. Only episodes viewers are about to reach are queued.",
Interval: 10 * time.Minute,
Timeout: 2 * time.Minute,
RunOnStart: true,
Run: func(ctx context.Context) (string, error) {
if !s.featureEnabled(ctx, featureEndCredits) {
return "", nil
}
return s.credits.Refresh(ctx)
},
})
}
// creditsQueueDetail is the one line the admin console prints about the queue.
func (s *Server) creditsQueueDetail() string {
if s.credits == nil {
return ""
}
depth := s.credits.QueueDepth()
if depth == 0 {
return ""
}
return fmt.Sprintf("%d episode%s awaiting credits detection", depth, plural(depth))
}
// playbackSessionKey identifies one stream for the load gauge.
//
// The play session where Emby issued one, because that is what distinguishes two
// simultaneous plays from one television reconnecting. A device id is the fallback: an older
// build sends no play session, and counting every one of those as the same stream would make
// a household of old televisions read as permanently idle.
func playbackSessionKey(deviceID, playSessionID string) string {
if playSessionID != "" {
return playSessionID
}
return deviceID
}
func plural(count int) string {
if count == 1 {
return ""
}
return "s"
}
+47 -14
View File
@@ -759,7 +759,8 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
}
matched := []active{}
for _, schedule := range schedules {
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) || now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) ||
!heroScheduleActiveAt(schedule, now, location) {
continue
}
matchesPlacement := false
@@ -775,19 +776,6 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
if !matchesPlacement {
continue
}
if len(schedule.Weekdays) > 0 {
weekday := int(now.In(location).Weekday())
found := false
for _, day := range schedule.Weekdays {
if day == weekday {
found = true
break
}
}
if !found {
continue
}
}
matched = append(matched, active{schedule.ItemID, schedule.Priority, schedule.StartAt})
}
sort.SliceStable(matched, func(i, j int) bool {
@@ -803,6 +791,51 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
return ids
}
func heroScheduleActiveAt(schedule store.HeroSchedule, now time.Time, location *time.Location) bool {
if location == nil {
location = time.Local
}
if schedule.Frequency != "daily" && schedule.Frequency != "weekly" {
if now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
return false
}
return len(schedule.Weekdays) == 0 || heroScheduleHasWeekday(schedule.Weekdays, int(now.In(location).Weekday()))
}
start, startErr := time.Parse("15:04", schedule.StartTime)
end, endErr := time.Parse("15:04", schedule.EndTime)
if startErr != nil || endErr != nil {
return false
}
local := now.In(location)
minute := local.Hour()*60 + local.Minute()
startMinute := start.Hour()*60 + start.Minute()
endMinute := end.Hour()*60 + end.Minute()
if startMinute == endMinute {
return false
}
effectiveDay := int(local.Weekday())
active := minute >= startMinute && minute < endMinute
if startMinute > endMinute {
active = minute >= startMinute || minute < endMinute
if minute < endMinute {
effectiveDay = (effectiveDay + 6) % 7
}
}
if !active || schedule.Frequency == "daily" || len(schedule.Weekdays) == 0 {
return active
}
return heroScheduleHasWeekday(schedule.Weekdays, effectiveDay)
}
func heroScheduleHasWeekday(days []int, weekday int) bool {
for _, day := range days {
if day == weekday {
return true
}
}
return false
}
// pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or
// unsupported id quietly drops out, so an old admin choice can never make Home fail.
func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate {
+30
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -22,6 +23,7 @@ type heroAdminPolicy struct {
PrimeSubtitle string `json:"primeSubtitle"`
Placements map[string]heroAdminPlacement `json:"placements"`
Schedules []store.HeroSchedule `json:"schedules"`
TimeZone string `json:"timeZone"`
}
type heroAdminPlacement struct {
@@ -67,6 +69,7 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
Placements: make(map[string]heroAdminPlacement, len(policy.Placements)),
Items: []heroAdminItem{},
Schedules: policy.Schedules,
TimeZone: heroScheduleTimeZone(s.cfg.RadarrLocation),
}
for _, id := range allIDs {
if item, ok := byID[id]; ok {
@@ -87,6 +90,13 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
return out
}
func heroScheduleTimeZone(location *time.Location) string {
if location == nil {
location = time.Local
}
return location.String()
}
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
if err != nil {
@@ -165,6 +175,26 @@ func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
}
}
for _, schedule := range request.Schedules {
frequency := strings.ToLower(strings.TrimSpace(schedule.Frequency))
if frequency != "" && frequency != "once" && frequency != "daily" && frequency != "weekly" {
writeError(w, http.StatusBadRequest, "hero schedule frequency must be once, daily or weekly")
return
}
if frequency == "daily" || frequency == "weekly" {
start, startErr := time.Parse("15:04", strings.TrimSpace(schedule.StartTime))
end, endErr := time.Parse("15:04", strings.TrimSpace(schedule.EndTime))
if startErr != nil || endErr != nil || start.Equal(end) {
writeError(w, http.StatusBadRequest, "repeating hero schedules need different start and end times")
return
}
if frequency == "weekly" && len(schedule.Weekdays) == 0 {
writeError(w, http.StatusBadRequest, "weekly hero schedules need at least one day")
return
}
} else if !schedule.EndAt.After(schedule.StartAt) {
writeError(w, http.StatusBadRequest, "one-time hero schedules need an end after their start")
return
}
item, ok := valid[strings.TrimSpace(schedule.ItemID)]
if !ok {
writeError(w, http.StatusBadRequest, "every scheduled hero must be a playable library film or series")
+39
View File
@@ -0,0 +1,39 @@
package api
import (
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestRecurringHeroScheduleUsesLocalWeekdayAndTime(t *testing.T) {
zone := time.FixedZone("NZST", 12*60*60)
schedule := store.HeroSchedule{
Frequency: "weekly", StartTime: "18:00", EndTime: "22:00",
Weekdays: []int{1},
}
monday := time.Date(2026, 8, 17, 19, 0, 0, 0, zone)
if !heroScheduleActiveAt(schedule, monday, zone) {
t.Fatal("Monday evening schedule was not active")
}
if heroScheduleActiveAt(schedule, monday.Add(24*time.Hour), zone) {
t.Fatal("Monday schedule remained active on Tuesday")
}
}
func TestOvernightHeroScheduleBelongsToStartingDay(t *testing.T) {
zone := time.FixedZone("NZST", 12*60*60)
schedule := store.HeroSchedule{
Frequency: "weekly", StartTime: "22:00", EndTime: "02:00",
Weekdays: []int{5}, // Friday night through early Saturday.
}
fridayNight := time.Date(2026, 8, 21, 23, 0, 0, 0, zone)
saturdayEarly := time.Date(2026, 8, 22, 1, 0, 0, 0, zone)
if !heroScheduleActiveAt(schedule, fridayNight, zone) || !heroScheduleActiveAt(schedule, saturdayEarly, zone) {
t.Fatal("overnight Friday schedule did not span midnight")
}
if heroScheduleActiveAt(schedule, saturdayEarly.Add(24*time.Hour), zone) {
t.Fatal("overnight Friday schedule was active on Sunday morning")
}
}
+10 -2
View File
@@ -161,8 +161,16 @@ func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.
// optional conveniences on a film that is already playing, and a failure the viewer
// cannot act on is not worth a red line in the log for every episode watched.
s.loggerFor(ctx).Debug("chapter markers unavailable", "item_id", itemID, "error", err)
writeJSON(w, http.StatusOK, introResponse{})
return
markers = chapterMarkers{}
}
// A discovered marker fills in where Emby has none, which on 4.10 is nearly everywhere:
// a survey of this household's library found no CreditsStart markers at all. Emby still
// wins where it has an answer — it is the media server's own reading of its own file, and
// this subsystem exists to cover the case where there is nothing to defer to.
if credits && !markers.creditsFound {
if start, found := s.discoveredCredits(ctx, itemID); found {
markers.creditsStart, markers.creditsFound = start, true
}
}
if !markers.introFound && !markers.creditsFound {
writeJSON(w, http.StatusOK, introResponse{})
+2
View File
@@ -22,6 +22,7 @@ import (
// handler has returned by the time the middleware reads this, so no lock is needed.
type requestIdentity struct {
component string
userID string
user string
device string
client string
@@ -55,6 +56,7 @@ func identify(ctx context.Context, sess store.Session) {
if identity == nil {
return
}
identity.userID = sess.EmbyUserID
if sess.Username != "" {
identity.user = sess.Username
}
+14 -5
View File
@@ -103,11 +103,20 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
// Nothing to celebrate while the service is down, and the client is showing the
// maintenance screen anyway.
if !state.Enabled {
if found := mergeAlerts(
s.publishedAlerts(r.Context()),
s.sonarrAiredAlerts(r.Context()),
); len(found) > 0 {
alerts = found
published, sonarr := s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context())
if len(published) > 0 || len(sonarr) > 0 {
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
if err != nil {
s.loggerFor(r.Context()).Warn("notification preferences unavailable", "error", err)
alerts = mergeAlerts(published, sonarr)
} else {
// Filter before trimming so three muted Sonarr stories cannot crowd a
// permitted Radarr or service notice out of the small client queue.
alerts = mergeAlerts(
filterClientAlerts(published, prefs),
filterClientAlerts(sonarr, prefs),
)
}
}
}
compatible, compatibilityMessage := compatibilityFor(r)
+4 -4
View File
@@ -155,7 +155,7 @@ func sonarrStatus(series sonarr.Series) string {
}
func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, sess store.Session) {
prefs, err := s.store.NotificationPreferences(r.Context(), sess.EmbyUserID)
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load notification preferences")
return
@@ -164,12 +164,12 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
if !decodeJSON(w, r, &prefs) {
return
}
if err := s.store.SetNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil {
if err := s.saveNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil {
writeError(w, http.StatusInternalServerError, "could not save notification preferences")
return
}
}
if prefs.Enabled && prefs.ShowReturnAlerts {
if prefs.Enabled && prefs.SonarrAlerts && prefs.ShowReturnAlerts {
s.syncReturnNotifications(r, sess, prefs)
}
notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID)
@@ -178,7 +178,7 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
return
}
writeJSON(w, http.StatusOK, notificationsResponse{
Notifications: notifications,
Notifications: filterStoredNotifications(notifications, prefs),
Preferences: prefs,
})
}
@@ -0,0 +1,100 @@
package api
import (
"context"
"encoding/json"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
const notificationPreferencesTTL = 5 * time.Minute
func notificationPreferencesKey(userID string) string {
return cache.UserKey(userID, "notification-preferences:v2")
}
// notificationPreferencesFor keeps the ten-second status poll off Postgres. Redis also
// shares the answer if the gateway is ever run with more than one replica; writes replace
// it immediately, while the short TTL still picks up an operator's direct database edit.
func (s *Server) notificationPreferencesFor(ctx context.Context, userID string) (store.NotificationPreferences, error) {
defaults := store.DefaultNotificationPreferences()
if userID == "" || s.store == nil {
return defaults, nil
}
if s.cache != nil {
if raw, err := s.cache.Get(ctx, notificationPreferencesKey(userID)); err == nil {
prefs := defaults
if json.Unmarshal(raw, &prefs) == nil {
return prefs, nil
}
}
}
prefs, err := s.store.NotificationPreferences(ctx, userID)
if err != nil {
return defaults, err
}
s.cacheNotificationPreferences(ctx, userID, prefs)
return prefs, nil
}
func (s *Server) cacheNotificationPreferences(ctx context.Context, userID string, prefs store.NotificationPreferences) {
if s.cache == nil || userID == "" {
return
}
if raw, err := json.Marshal(prefs); err == nil {
_ = s.cache.Set(ctx, notificationPreferencesKey(userID), raw, notificationPreferencesTTL)
}
}
func (s *Server) saveNotificationPreferences(ctx context.Context, userID string, prefs store.NotificationPreferences) error {
if err := s.store.SetNotificationPreferences(ctx, userID, prefs); err != nil {
return err
}
s.cacheNotificationPreferences(ctx, userID, prefs)
return nil
}
func filterClientAlerts(alerts []clientAlert, prefs store.NotificationPreferences) []clientAlert {
if !prefs.Enabled {
return []clientAlert{}
}
filtered := make([]clientAlert, 0, len(alerts))
for _, alert := range alerts {
allowed := true
switch alert.Kind {
case alertKindSonarrAired:
allowed = prefs.SonarrAlerts
case alertKindRadarrImport:
allowed = prefs.RadarrAlerts
case alertKindLibrarySync:
allowed = prefs.LibraryAlerts
case alertKindServerDown, alertKindServerUp, alertKindDeploying:
allowed = prefs.SystemAlerts
}
if allowed {
filtered = append(filtered, alert)
}
}
return filtered
}
func filterStoredNotifications(notifications []store.UserNotification, prefs store.NotificationPreferences) []store.UserNotification {
if !prefs.Enabled {
return []store.UserNotification{}
}
if prefs.SonarrAlerts {
return notifications
}
filtered := make([]store.UserNotification, 0, len(notifications))
for _, notification := range notifications {
switch notification.Kind {
case "show-return", "show-added", "show-cancelled", "auto-follow":
continue
default:
filtered = append(filtered, notification)
}
}
return filtered
}
@@ -0,0 +1,57 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestFilterClientAlertsByCategory(t *testing.T) {
prefs := store.DefaultNotificationPreferences()
prefs.SonarrAlerts = false
prefs.LibraryAlerts = false
alerts := []clientAlert{
{ID: "sonarr", Kind: alertKindSonarrAired},
{ID: "radarr", Kind: alertKindRadarrImport},
{ID: "library", Kind: alertKindLibrarySync},
{ID: "system", Kind: alertKindServerDown},
}
filtered := filterClientAlerts(alerts, prefs)
if len(filtered) != 2 || filtered[0].ID != "radarr" || filtered[1].ID != "system" {
t.Fatalf("filtered alerts = %#v", filtered)
}
}
func TestFilterClientAlertsMasterSwitch(t *testing.T) {
prefs := store.DefaultNotificationPreferences()
prefs.Enabled = false
if got := filterClientAlerts([]clientAlert{{ID: "one", Kind: alertKindRadarrImport}}, prefs); len(got) != 0 {
t.Fatalf("master switch returned %#v", got)
}
}
func TestFilterStoredSonarrNotifications(t *testing.T) {
prefs := store.DefaultNotificationPreferences()
prefs.SonarrAlerts = false
notifications := []store.UserNotification{
{ID: 1, Kind: "show-return"},
{ID: 2, Kind: "future-category"},
}
filtered := filterStoredNotifications(notifications, prefs)
if len(filtered) != 1 || filtered[0].ID != 2 {
t.Fatalf("filtered notifications = %#v", filtered)
}
}
func TestUpdatePreferenceNeverSuppressesMandatoryUpdate(t *testing.T) {
mandatory := appupdate.Decision{Status: appupdate.StatusMandatory, Version: "2.0.0"}
if got := updateDecisionForPreferences(mandatory, false); got.Status != appupdate.StatusMandatory {
t.Fatalf("mandatory update was suppressed: %#v", got)
}
optional := appupdate.Decision{Status: appupdate.StatusOptional, Version: "2.0.0"}
if got := updateDecisionForPreferences(optional, false); got.Status != appupdate.StatusNone {
t.Fatalf("optional update was not suppressed: %#v", got)
}
}
+6
View File
@@ -812,6 +812,12 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
)
}
// Credits discovery rides the reports that were already being made. The session key is
// the play session where the television gave one and the device otherwise, so two
// televisions playing the same episode count as two streams rather than one.
s.noteCreditsPlayback(r.Context(), phase, report.ItemID,
playbackSessionKey(sess.DeviceID, report.PlaySessionID))
if phase == "stopped" {
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
+83
View File
@@ -0,0 +1,83 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"regexp"
"strings"
"time"
)
var releaseBuilderTagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
type releaseBuilderRequest struct {
Tag string `json:"tag"`
Notes string `json:"notes"`
Mandatory bool `json:"mandatory"`
}
func (s *Server) handleAdminReleaseBuilderStatus(w http.ResponseWriter, r *http.Request) {
s.relayReleaseBuilder(w, r, http.MethodGet, "/v1/status", nil)
}
func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
var request releaseBuilderRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid release request")
return
}
request.Tag = strings.TrimSpace(request.Tag)
request.Notes = strings.TrimSpace(request.Notes)
if request.Tag != "" && !releaseBuilderTagPattern.MatchString(request.Tag) {
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
return
}
if len(request.Notes) > 4000 {
writeError(w, http.StatusBadRequest, "release notes are too long")
return
}
body, err := json.Marshal(request)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not prepare release request")
return
}
s.relayReleaseBuilder(w, r, http.MethodPost, "/v1/releases", body)
}
func (s *Server) relayReleaseBuilder(w http.ResponseWriter, incoming *http.Request, method, path string, body []byte) {
if s.cfg.ReleaseBuilderURL == "" || s.cfg.ReleasePublishToken == "" {
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not configured")
return
}
request, err := http.NewRequestWithContext(incoming.Context(), method,
s.cfg.ReleaseBuilderURL+path, bytes.NewReader(body))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not prepare builder request")
return
}
request.Header.Set("Authorization", "Bearer "+s.cfg.ReleasePublishToken)
if len(body) > 0 {
request.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not available")
return
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
writeError(w, http.StatusBadGateway, "could not read the Docker release builder response")
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(response.StatusCode)
_, _ = w.Write(payload)
}
@@ -0,0 +1,62 @@
package api
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestAdminReleaseBuilderRelaysWithoutExposingToken(t *testing.T) {
var receivedAuth string
builder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
if r.Method != http.MethodPost || r.URL.Path != "/v1/releases" {
t.Fatalf("builder request = %s %s", r.Method, r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"tag":"v0.2.64"`) || !strings.Contains(string(body), `"mandatory":true`) {
t.Fatalf("builder body = %s", body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"state":"running","logs":[]}`)
}))
defer builder.Close()
s := &Server{cfg: config.Config{ReleaseBuilderURL: builder.URL, ReleasePublishToken: "release-secret"}}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
strings.NewReader(`{"tag":"v0.2.64","notes":"Living room polish","mandatory":true}`))
s.handleAdminReleaseBuilderStart(recorder, request)
if recorder.Code != http.StatusAccepted || receivedAuth != "Bearer release-secret" {
t.Fatalf("response/auth = %d/%q", recorder.Code, receivedAuth)
}
if strings.Contains(recorder.Body.String(), "release-secret") {
t.Fatal("release token was exposed to the browser")
}
}
func TestAdminReleaseBuilderValidatesTagBeforeRelay(t *testing.T) {
s := &Server{cfg: config.Config{ReleaseBuilderURL: "http://builder", ReleasePublishToken: "secret"}}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
strings.NewReader(`{"tag":"latest; rm -rf /"}`))
s.handleAdminReleaseBuilderStart(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid tag status = %d, want 400", recorder.Code)
}
}
func TestAdminReleaseBuilderIsUnavailableWhenUnconfigured(t *testing.T) {
s := &Server{}
recorder := httptest.NewRecorder()
s.handleAdminReleaseBuilderStatus(recorder, httptest.NewRequest(http.MethodGet, "/admin/api/release-builder", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured builder status = %d, want 503", recorder.Code)
}
}
+2 -2
View File
@@ -92,7 +92,7 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
}
preferences[user.ID] = prefs
}
if preferenceErrors[user.ID] || !prefs.Enabled {
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
continue
}
eventAt := change.Current.ObservedAt
@@ -122,7 +122,7 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
}
preferences[user.ID] = prefs
}
if preferenceErrors[user.ID] || !prefs.Enabled {
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
continue
}
eventAt := change.Current.ObservedAt
+23 -3
View File
@@ -148,11 +148,24 @@ func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc
// handleUpdate answers the client's version check.
//
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
// app build, not a viewer or login. The verdict comes from memory; when a bearer token is
// present the route resolves it only to attribute an offered update to the affected viewer.
// Its own public endpoint rather than a field on /v1/home: compatibility belongs to the
// app build, not a viewer or login. The verdict comes from memory; a valid bearer token
// also lets one viewer mute an optional prompt, but never a mandatory update.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
decision := s.updateDecision(r)
// A signed-in viewer may decline optional update prompts. Mandatory decisions remain
// authoritative: this preference is about notifications, not compatibility or the
// operator's ability to retire an unsafe build. Signed-out checks have no person to
// consult and retain the ordinary app-scoped policy.
if decision.Status == appupdate.StatusOptional {
if identity := identityFrom(r.Context()); identity != nil && identity.userID != "" {
if prefs, err := s.notificationPreferencesFor(r.Context(), identity.userID); err != nil {
s.loggerFor(r.Context()).Warn("update notification preferences unavailable", "error", err)
} else {
decision = updateDecisionForPreferences(decision, prefs.Enabled && prefs.UpdateAlerts)
}
}
}
// Only a verdict that asks a television to do something is worth a line. Every TV
// checks on every launch, and "nothing to say" logged each time would bury the
// launch where an update was actually offered — or forced.
@@ -165,3 +178,10 @@ func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, decision)
}
func updateDecisionForPreferences(decision appupdate.Decision, updateAlerts bool) appupdate.Decision {
if decision.Status == appupdate.StatusOptional && !updateAlerts {
return appupdate.Decision{Status: appupdate.StatusNone}
}
return decision
}