0.2.64 update
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user