0.1.52 Gateway
This commit is contained in:
@@ -56,6 +56,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
||||
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
||||
mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
|
||||
mux.Handle("POST /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
|
||||
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
|
||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||
@@ -867,4 +869,5 @@ func (s *Server) handleAdminViews(w http.ResponseWriter, r *http.Request) {
|
||||
type syncerHandle interface {
|
||||
Running() bool
|
||||
Sync(ctx context.Context, kind, trigger string) (library.Result, error)
|
||||
Find(ctx context.Context, term string, limit int) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
@@ -286,6 +286,10 @@ func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request)
|
||||
s.loggerFor(r.Context()).Warn("device version cleanup failed",
|
||||
"removed_device_id", deviceID, "error", err)
|
||||
}
|
||||
if err := s.store.DeleteDeviceActivityDays(r.Context(), deviceID); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("device activity cleanup failed",
|
||||
"removed_device_id", deviceID, "error", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// adminGatewaySettingsResponse is deliberately three things at once: what the operator has
|
||||
// chosen, what the container was started with, and what is therefore in force. A settings
|
||||
// page that showed only the first would leave every empty field looking like a value of
|
||||
// nothing, when an empty field here means "whatever .env says" — and the operator has no
|
||||
// other way to see what that is without opening a file on the NAS.
|
||||
type adminGatewaySettingsResponse struct {
|
||||
Settings store.GatewaySettings `json:"settings"`
|
||||
Deployed deployedGatewaySettings `json:"deployed"`
|
||||
Effective deployedGatewaySettings `json:"effective"`
|
||||
// LogLevels is the vocabulary rather than a list the console keeps its own copy of,
|
||||
// for the reason the preference catalogue is served with the accounts page: a value
|
||||
// the server would refuse must never be offerable.
|
||||
LogLevels []string `json:"logLevels"`
|
||||
// Version and Timezone name the process this page is about, so the page can identify
|
||||
// the gateway it is changing without a second request.
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
|
||||
settings := s.gatewaySettings.get()
|
||||
return adminGatewaySettingsResponse{
|
||||
Settings: settings,
|
||||
Deployed: s.deployedSettings(),
|
||||
Effective: deployedGatewaySettings{
|
||||
Timezone: s.householdTimezoneName(),
|
||||
LogLevel: s.effectiveLogLevel(),
|
||||
SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)),
|
||||
SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute),
|
||||
RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute),
|
||||
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
|
||||
},
|
||||
LogLevels: store.GatewayLogLevels,
|
||||
Version: buildinfo.Version(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) effectiveLogLevel() string {
|
||||
if s.logLevel != nil {
|
||||
return levelName(s.logLevel.Level())
|
||||
}
|
||||
return levelName(s.deployedLogLevel)
|
||||
}
|
||||
|
||||
// handleAdminGatewaySettings serves the page and accepts a write on the same path, which
|
||||
// is the shape the *arr policy routes already take.
|
||||
func (s *Server) handleAdminGatewaySettings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, s.gatewaySettingsResponse())
|
||||
return
|
||||
}
|
||||
var req store.GatewaySettings
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
previous := s.gatewaySettings.get()
|
||||
_, operator, _ := s.browserSession(r, adminSessionPurpose)
|
||||
req.UpdatedBy = operator
|
||||
stored, err := s.store.SetGatewaySettings(r.Context(), req)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("gateway settings write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save gateway settings")
|
||||
return
|
||||
}
|
||||
// Applied here rather than waited for: the watcher would pick this up within thirty
|
||||
// seconds, and an operator who has just turned debug logging on and gone to look at
|
||||
// the log would spend that half minute believing it had not worked.
|
||||
s.gatewaySettings.set(stored)
|
||||
s.applyLogLevel(stored.LogLevel)
|
||||
|
||||
s.loggerFor(r.Context()).Info("gateway settings changed",
|
||||
"timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(),
|
||||
"session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)),
|
||||
"operator", operator)
|
||||
if summary := gatewaySettingsChanges(previous, stored); summary != "" {
|
||||
s.publishAdmin(r.Context(), adminevents.Event{
|
||||
Type: adminevents.TypeSettingsChanged,
|
||||
Severity: adminevents.SeverityWarning,
|
||||
Title: "Gateway settings changed",
|
||||
Summary: summary,
|
||||
Actor: operator,
|
||||
Link: "/admin/settings",
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.gatewaySettingsResponse())
|
||||
}
|
||||
|
||||
// gatewaySettingsChanges is what the notification says, and returns empty when nothing
|
||||
// moved. The console re-posts the whole document, so a save that changed nothing is an
|
||||
// ordinary event — the preferenceChanges rule, for the same reason: a feed that records
|
||||
// every press of Save is one nobody reads.
|
||||
func gatewaySettingsChanges(before, after store.GatewaySettings) string {
|
||||
changes := []string{}
|
||||
if before.Timezone != after.Timezone {
|
||||
changes = append(changes, "timezone")
|
||||
}
|
||||
if before.LogLevel != after.LogLevel {
|
||||
changes = append(changes, "log level")
|
||||
}
|
||||
if before.SessionIdleDays != after.SessionIdleDays {
|
||||
changes = append(changes, "session expiry")
|
||||
}
|
||||
if before.SonarrAlertMinutes != after.SonarrAlertMinutes {
|
||||
changes = append(changes, "episode alert window")
|
||||
}
|
||||
if before.RadarrAlertMinutes != after.RadarrAlertMinutes {
|
||||
changes = append(changes, "film alert window")
|
||||
}
|
||||
if before.EmbyHealthSeconds != after.EmbyHealthSeconds {
|
||||
changes = append(changes, "Emby health probe")
|
||||
}
|
||||
switch len(changes) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return "The gateway's " + changes[0] + " was changed"
|
||||
default:
|
||||
return "The gateway's " + joinPhrase(changes) + " were changed"
|
||||
}
|
||||
}
|
||||
|
||||
func joinPhrase(values []string) string {
|
||||
switch len(values) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return values[0]
|
||||
}
|
||||
out := ""
|
||||
for index, value := range values[:len(values)-1] {
|
||||
if index > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += value
|
||||
}
|
||||
return out + " and " + values[len(values)-1]
|
||||
}
|
||||
@@ -30,6 +30,8 @@ type integrationEvent struct {
|
||||
var integrationEventCatalogue = []integrationEvent{
|
||||
{adminevents.TypeLogin, "User signed in", "A television signed in with a known device.", "Access"},
|
||||
{adminevents.TypeDeviceRegistered, "New device", "A television signed in for the first time.", "Access"},
|
||||
{adminevents.TypeDeviceFirstUse, "First use today", "A television opened Memby for the first time that day.", "Access"},
|
||||
{adminevents.TypeDeviceUpdated, "App updated", "A television finished updating to a new build.", "Access"},
|
||||
{adminevents.TypeLoginFailed, "Sign-in refused", "Emby refused the credentials offered.", "Access"},
|
||||
{adminevents.TypeLogout, "User signed out", "A television signed itself out.", "Access"},
|
||||
{adminevents.TypeDeviceRemoved, "Device removed", "A television was removed from an account.", "Access"},
|
||||
@@ -37,6 +39,7 @@ var integrationEventCatalogue = []integrationEvent{
|
||||
{adminevents.TypeAdminSignIn, "Admin sign-in", "Somebody signed into this console.", "Access"},
|
||||
{adminevents.TypeServerStarted, "Server started", "The gateway came up, usually after a deployment.", "System"},
|
||||
{adminevents.TypeMaintenanceChanged, "Maintenance changed", "Memby was taken offline or brought back.", "System"},
|
||||
{adminevents.TypeSettingsChanged, "Gateway settings changed", "A server-level setting was changed in the console.", "System"},
|
||||
{adminevents.TypeTaskCompleted, "Scheduled task finished", "A background job did some work.", "System"},
|
||||
{adminevents.TypeTaskFailed, "Scheduled task failed", "A background job could not complete.", "System"},
|
||||
{adminevents.TypeIntegrationFailed, "Integration failed", "An outgoing webhook could not be delivered.", "System"},
|
||||
|
||||
@@ -131,7 +131,7 @@ func (s *Server) handleAdminRecommendations(w http.ResponseWriter, r *http.Reque
|
||||
contextName = "default"
|
||||
}
|
||||
intent := recommend.RankIntent{
|
||||
ID: "admin:" + contextName, Now: now, Location: s.cfg.SonarrLocation,
|
||||
ID: "admin:" + contextName, Now: now, Location: s.householdLocation(),
|
||||
HouseholdScores: household, Compatibility: map[string]float64{},
|
||||
}
|
||||
switch contextName {
|
||||
|
||||
@@ -180,7 +180,8 @@ func liveAlerts(stored []storedAlert, now time.Time) []clientAlert {
|
||||
// sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row
|
||||
// uses, so polling clients never cost a Sonarr request of their own.
|
||||
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
|
||||
if !s.sonarrEnabled(ctx) || s.cfg.SonarrAlertWindow <= 0 {
|
||||
window := s.sonarrAlertWindow()
|
||||
if !s.sonarrEnabled(ctx) || window <= 0 {
|
||||
return nil
|
||||
}
|
||||
row, err := s.sonarrAiringTodayRow(ctx)
|
||||
@@ -199,11 +200,7 @@ func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
return buildSonarrAlerts(items, time.Now().In(location), s.cfg.SonarrAlertWindow)
|
||||
return buildSonarrAlerts(items, time.Now().In(s.householdLocation()), window)
|
||||
}
|
||||
|
||||
// buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the
|
||||
|
||||
@@ -99,7 +99,14 @@ type Server struct {
|
||||
recommendationBuilds recommendationBuilds
|
||||
maintenance maintenanceState
|
||||
quietTime quietTimeState
|
||||
updatePolicy updatePolicyCache
|
||||
// gatewaySettings is the operator's runtime amendment to what the container was
|
||||
// started with. deployedLogLevel is remembered beside the live level variable
|
||||
// because clearing an override has to restore something, and the level variable
|
||||
// itself has by then been moved.
|
||||
gatewaySettings gatewaySettingsState
|
||||
logLevel *slog.LevelVar
|
||||
deployedLogLevel slog.Level
|
||||
updatePolicy updatePolicyCache
|
||||
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
|
||||
// a TV can show why playback stopped even if it missed the announcement.
|
||||
embyHealth embyHealth
|
||||
@@ -129,6 +136,10 @@ type Deps struct {
|
||||
AdminEvents *adminevents.Bus
|
||||
Scheduler *scheduler.Scheduler
|
||||
Integrations *integrations.Dispatcher
|
||||
// LogLevel is the live level of the process's own logger, so the console can turn
|
||||
// debug on and watch the thing it turned it on for. Nil is allowed and means the
|
||||
// level is fixed at whatever the container was started with.
|
||||
LogLevel *slog.LevelVar
|
||||
}
|
||||
|
||||
func New(cfg config.Config, deps Deps) *Server {
|
||||
@@ -152,9 +163,19 @@ func New(cfg config.Config, deps Deps) *Server {
|
||||
adminEvents: deps.AdminEvents,
|
||||
scheduler: deps.Scheduler,
|
||||
integrations: deps.Integrations,
|
||||
|
||||
logLevel: deps.LogLevel,
|
||||
deployedLogLevel: deployedLevel(deps.LogLevel),
|
||||
}
|
||||
}
|
||||
|
||||
func deployedLevel(level *slog.LevelVar) slog.Level {
|
||||
if level == nil {
|
||||
return slog.LevelInfo
|
||||
}
|
||||
return level.Level()
|
||||
}
|
||||
|
||||
// publishAdmin reports something an operator would want to know about.
|
||||
//
|
||||
// Every caller treats it as fire-and-forget, which is why it returns nothing: the feed is
|
||||
@@ -409,6 +430,9 @@ func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) stor
|
||||
s.log.Warn("device version record failed",
|
||||
"device_id", sess.DeviceID, "error", err)
|
||||
}
|
||||
// And it is the only place an operator would otherwise learn that an update
|
||||
// they offered was actually taken.
|
||||
s.announceDeviceUpdate(r.Context(), sess, previousVersion)
|
||||
}
|
||||
}
|
||||
return sess
|
||||
|
||||
@@ -297,6 +297,10 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, curr
|
||||
s.loggerFor(r.Context()).Warn("device version cleanup failed",
|
||||
"removed_device_id", deviceID, "error", err)
|
||||
}
|
||||
if err := s.store.DeleteDeviceActivityDays(r.Context(), deviceID); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("device activity cleanup failed",
|
||||
"removed_device_id", deviceID, "error", err)
|
||||
}
|
||||
// A device disappearing from a household is worth a line: the next thing that TV
|
||||
// reports is a sign-in, and the two together explain each other.
|
||||
s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID)
|
||||
@@ -335,6 +339,9 @@ func (s *Server) retireSupersededDevices(ctx context.Context, devices []store.Su
|
||||
if err := s.store.DeleteDeviceVersions(ctx, ids...); err != nil {
|
||||
s.loggerFor(ctx).Warn("device version cleanup failed", "error", err)
|
||||
}
|
||||
if err := s.store.DeleteDeviceActivityDays(ctx, ids...); err != nil {
|
||||
s.loggerFor(ctx).Warn("device activity cleanup failed", "error", err)
|
||||
}
|
||||
s.loggerFor(ctx).Info("device identity superseded", "retired_device_ids", ids)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.
|
||||
}
|
||||
|
||||
func (s *Server) sonarrLocation() *time.Location {
|
||||
if s.cfg.SonarrLocation != nil {
|
||||
return s.cfg.SonarrLocation
|
||||
}
|
||||
return time.Local
|
||||
return s.householdLocation()
|
||||
}
|
||||
|
||||
func (s *Server) sonarrCalendarMonth(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// noteDailyFirstUse announces the first time a television opened Memby on a given day.
|
||||
//
|
||||
// It is called from the home request rather than from the auth middleware, which every
|
||||
// call passes through, because "opened Memby" means the launcher composing. A set left on
|
||||
// overnight polls /v1/status every ten seconds, so anchoring on any authenticated request
|
||||
// would announce that set at midnight — an event nobody did, timed to the hour nobody is
|
||||
// reading the feed.
|
||||
//
|
||||
// It is called before the cached response is served, deliberately: a household whose home
|
||||
// row cache is still warm from the last set to switch on has still just been opened by
|
||||
// this one, and the mark is a single indexed insert either way.
|
||||
//
|
||||
// Everything about it is best-effort. It cannot fail a launcher.
|
||||
func (s *Server) noteDailyFirstUse(ctx context.Context, sess store.Session) {
|
||||
if s.store == nil || sess.DeviceID == "" {
|
||||
return
|
||||
}
|
||||
first, err := s.store.MarkDeviceDay(
|
||||
ctx, sess.DeviceID, sess.EmbyUserID, time.Now().In(s.sonarrLocation()),
|
||||
)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("device day mark failed",
|
||||
"device_id", sess.DeviceID, "error", err)
|
||||
return
|
||||
}
|
||||
if !first {
|
||||
return
|
||||
}
|
||||
s.loggerFor(ctx).Info("first use today",
|
||||
"device_id", sess.DeviceID, "client", clientLogValue(sess.ClientVersion))
|
||||
s.publishAdmin(ctx, adminevents.Event{
|
||||
Type: adminevents.TypeDeviceFirstUse,
|
||||
Title: "Opened Memby",
|
||||
Summary: fmt.Sprintf("%s opened Memby on %s for the first time today",
|
||||
displayName(sess.Username), displayName(sess.DeviceName)),
|
||||
Actor: sess.Username, Target: sess.DeviceName,
|
||||
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
|
||||
Metadata: adminevents.Meta(map[string]any{
|
||||
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
|
||||
"version": sess.ClientVersion,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// announceDeviceUpdate reports a television that has finished updating itself.
|
||||
//
|
||||
// A set that updates in place never signs in again, so the first request carrying the new
|
||||
// build is the only evidence there is that the update worked — and an update that was
|
||||
// offered and never taken looks exactly like one that was, until this arrives.
|
||||
//
|
||||
// `from` being empty is not an update: it is a television this gateway had never been
|
||||
// told the build of, which is an old APK or a session predating identity capture, and
|
||||
// announcing it as an upgrade would claim a version moved when nothing is known to have
|
||||
// moved. A version that went *backwards* is still announced, because a sideloaded
|
||||
// downgrade is news an operator wants at least as much as an upgrade.
|
||||
func (s *Server) announceDeviceUpdate(ctx context.Context, sess store.Session, from string) {
|
||||
if from == "" || sess.ClientVersion == "" || from == sess.ClientVersion {
|
||||
return
|
||||
}
|
||||
s.loggerFor(ctx).Info("client build changed",
|
||||
"device_id", sess.DeviceID, "from", from, "to", sess.ClientVersion)
|
||||
s.publishAdmin(ctx, adminevents.Event{
|
||||
Type: adminevents.TypeDeviceUpdated,
|
||||
Title: "App updated",
|
||||
Summary: fmt.Sprintf("%s updated from %s to %s",
|
||||
displayName(sess.DeviceName), from, sess.ClientVersion),
|
||||
Actor: sess.Username, Target: sess.DeviceName,
|
||||
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
|
||||
Metadata: adminevents.Meta(map[string]any{
|
||||
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
|
||||
"from": from, "to": sess.ClientVersion,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,28 @@ func (h *embyHealth) begin(interval time.Duration, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// retune follows the operator changing the probe's cadence, or switching it off, without
|
||||
// disturbing what the probe has already found. It is deliberately not `begin`: that
|
||||
// starts a fresh watch and drops the Emby version with it, which the About page reads and
|
||||
// which is still true whatever the interval is now.
|
||||
func (h *embyHealth) retune(interval time.Duration, now time.Time) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if interval <= 0 {
|
||||
h.state.monitored = false
|
||||
h.state.retryEvery = 0
|
||||
return
|
||||
}
|
||||
if !h.state.monitored {
|
||||
h.state = embyHealthState{
|
||||
monitored: true, reachable: true, since: now, retryEvery: interval,
|
||||
version: h.state.version,
|
||||
}
|
||||
return
|
||||
}
|
||||
h.state.retryEvery = interval
|
||||
}
|
||||
|
||||
// record folds one probe result in and reports whether the published verdict changed.
|
||||
func (h *embyHealth) record(ok bool, version string, now time.Time) {
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// gatewaySettingsState caches the operator's overrides in memory, the way maintenance is
|
||||
// cached: several of these are read on the request path — the household's timezone is
|
||||
// read by every home response — and none of them is worth a query.
|
||||
type gatewaySettingsState struct {
|
||||
mu sync.RWMutex
|
||||
value store.GatewaySettings
|
||||
}
|
||||
|
||||
func (g *gatewaySettingsState) get() store.GatewaySettings {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.value
|
||||
}
|
||||
|
||||
func (g *gatewaySettingsState) set(value store.GatewaySettings) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.value = value
|
||||
}
|
||||
|
||||
// LoadGatewaySettings primes the cache and applies the settings that live in the running
|
||||
// process rather than being read where they are used. Called at boot and after a write.
|
||||
func (s *Server) LoadGatewaySettings(ctx context.Context) error {
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
settings, err := s.store.GatewaySettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.gatewaySettings.set(settings)
|
||||
s.applyLogLevel(settings.LogLevel)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WatchGatewaySettings re-reads the overrides periodically, so a change made directly in
|
||||
// the database — or by another instance — is picked up without a restart. The same reason
|
||||
// WatchMaintenance exists.
|
||||
func (s *Server) WatchGatewaySettings(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.LoadGatewaySettings(ctx); err != nil {
|
||||
s.log.Warn("gateway settings refresh failed",
|
||||
"component", "settings", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyLogLevel moves the running process's log level. It is a no-op on a gateway wired
|
||||
// without a level variable — every unit test in this package — and an empty override
|
||||
// restores the level the container was started with, which is what makes clearing the
|
||||
// setting in the console a real undo rather than a value the operator has to remember.
|
||||
func (s *Server) applyLogLevel(level string) {
|
||||
if s.logLevel == nil {
|
||||
return
|
||||
}
|
||||
if level == "" {
|
||||
s.logLevel.Set(s.deployedLogLevel)
|
||||
return
|
||||
}
|
||||
s.logLevel.Set(logging.ParseLevel(level))
|
||||
}
|
||||
|
||||
// --- effective values -------------------------------------------------------
|
||||
//
|
||||
// Each of these is "the override, or what was deployed". They are the only readers of the
|
||||
// cached document, so a setting is added by adding one of these beside its config field
|
||||
// rather than by teaching every call site that an override exists.
|
||||
|
||||
// householdLocation is the household's idea of what day it is. Everything that groups by
|
||||
// a local day reads it: the schedule rows, the hero rotation, sign-in history and the
|
||||
// first-use notification.
|
||||
func (s *Server) householdLocation() *time.Location {
|
||||
if name := s.gatewaySettings.get().Timezone; name != "" {
|
||||
if location, err := time.LoadLocation(name); err == nil {
|
||||
return location
|
||||
}
|
||||
}
|
||||
if s.cfg.SonarrLocation != nil {
|
||||
return s.cfg.SonarrLocation
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
// householdTimezoneName is what the console and the sign-in history print. It names the
|
||||
// zone in force rather than the one deployed, so a page cannot claim a grouping that is
|
||||
// not the one the rows were grouped by.
|
||||
func (s *Server) householdTimezoneName() string {
|
||||
return s.householdLocation().String()
|
||||
}
|
||||
|
||||
func (s *Server) sessionIdleExpiry() time.Duration {
|
||||
if days := s.gatewaySettings.get().SessionIdleDays; days > 0 {
|
||||
return time.Duration(days) * 24 * time.Hour
|
||||
}
|
||||
return s.cfg.SessionIdleExpiry
|
||||
}
|
||||
|
||||
func (s *Server) sonarrAlertWindow() time.Duration {
|
||||
return overrideWindow(s.gatewaySettings.get().SonarrAlertMinutes, time.Minute,
|
||||
s.cfg.SonarrAlertWindow)
|
||||
}
|
||||
|
||||
func (s *Server) radarrAlertWindow() time.Duration {
|
||||
return overrideWindow(s.gatewaySettings.get().RadarrAlertMinutes, time.Minute,
|
||||
s.cfg.RadarrAlertWindow)
|
||||
}
|
||||
|
||||
func (s *Server) embyHealthInterval() time.Duration {
|
||||
return overrideWindow(s.gatewaySettings.get().EmbyHealthSeconds, time.Second,
|
||||
s.cfg.EmbyHealthInterval)
|
||||
}
|
||||
|
||||
// overrideWindow reads one of the three settings that can be switched off: a negative
|
||||
// value is off, zero is "whatever was deployed", anything else is the override in the
|
||||
// given unit.
|
||||
func overrideWindow(value int, unit, deployed time.Duration) time.Duration {
|
||||
switch {
|
||||
case value < 0:
|
||||
return 0
|
||||
case value > 0:
|
||||
return time.Duration(value) * unit
|
||||
default:
|
||||
return deployed
|
||||
}
|
||||
}
|
||||
|
||||
// deployedGatewaySettings describes what the container was started with, so the console
|
||||
// can show the value a cleared field falls back to. Deliberately not the same shape as
|
||||
// the overrides: these are facts, not choices, and nothing may write them back.
|
||||
type deployedGatewaySettings struct {
|
||||
Timezone string `json:"timezone"`
|
||||
LogLevel string `json:"logLevel"`
|
||||
SessionIdleDays int `json:"sessionIdleDays"`
|
||||
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
|
||||
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
|
||||
EmbyHealthSeconds int `json:"embyHealthSeconds"`
|
||||
}
|
||||
|
||||
func (s *Server) deployedSettings() deployedGatewaySettings {
|
||||
timezone := ""
|
||||
if s.cfg.SonarrLocation != nil {
|
||||
timezone = s.cfg.SonarrLocation.String()
|
||||
}
|
||||
return deployedGatewaySettings{
|
||||
Timezone: timezone,
|
||||
LogLevel: levelName(s.deployedLogLevel),
|
||||
SessionIdleDays: int(s.cfg.SessionIdleExpiry / (24 * time.Hour)),
|
||||
SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute),
|
||||
RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute),
|
||||
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func levelName(level slog.Level) string {
|
||||
switch {
|
||||
case level < slog.LevelDebug:
|
||||
return "trace"
|
||||
case level < slog.LevelInfo:
|
||||
return "debug"
|
||||
case level < slog.LevelWarn:
|
||||
return "info"
|
||||
case level < slog.LevelError:
|
||||
return "warn"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The three meanings a duration-like override carries. Zero is not "off" — that is the
|
||||
// whole point of the -1, and reading it as off would silently disable an alert window an
|
||||
// operator had merely left alone.
|
||||
func TestOverrideWindowSeparatesOffFromDeployed(t *testing.T) {
|
||||
deployed := 15 * time.Minute
|
||||
if got := overrideWindow(0, time.Minute, deployed); got != deployed {
|
||||
t.Fatalf("zero should fall back to the deployed value, got %s", got)
|
||||
}
|
||||
if got := overrideWindow(store.GatewaySettingsOff, time.Minute, deployed); got != 0 {
|
||||
t.Fatalf("a negative override should switch the window off, got %s", got)
|
||||
}
|
||||
if got := overrideWindow(45, time.Minute, deployed); got != 45*time.Minute {
|
||||
t.Fatalf("a positive override should be taken in the given unit, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The probe loop must keep ticking while it is switched off, or turning it back on would
|
||||
// need a restart of the gateway — which is the thing the setting exists to avoid.
|
||||
func TestEmbyProbeDelayKeepsTickingWhileOff(t *testing.T) {
|
||||
if got := embyProbeDelay(0); got != time.Minute {
|
||||
t.Fatalf("a switched-off probe should still wake up, got %s", got)
|
||||
}
|
||||
if got := embyProbeDelay(30 * time.Second); got != 30*time.Second {
|
||||
t.Fatalf("a live probe should wait its interval, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewaySettingsChangesReportsOnlyWhatMoved(t *testing.T) {
|
||||
before := store.GatewaySettings{Timezone: "Pacific/Auckland", SessionIdleDays: 30}
|
||||
if summary := gatewaySettingsChanges(before, before); summary != "" {
|
||||
t.Fatalf("an unchanged save must not be news, got %q", summary)
|
||||
}
|
||||
one := before
|
||||
one.LogLevel = "debug"
|
||||
if summary := gatewaySettingsChanges(before, one); summary != "The gateway's log level was changed" {
|
||||
t.Fatalf("one change reads wrong: %q", summary)
|
||||
}
|
||||
two := one
|
||||
two.SessionIdleDays = 60
|
||||
summary := gatewaySettingsChanges(before, two)
|
||||
if summary != "The gateway's log level and session expiry were changed" {
|
||||
t.Fatalf("two changes read wrong: %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelNameCoversTheVocabulary(t *testing.T) {
|
||||
for _, level := range store.GatewayLogLevels {
|
||||
if got := levelName(parseTestLevel(t, level)); got != level {
|
||||
t.Fatalf("%s round-tripped as %s", level, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseTestLevel(t *testing.T, name string) slog.Level {
|
||||
t.Helper()
|
||||
return logging.ParseLevel(name)
|
||||
}
|
||||
@@ -1055,10 +1055,7 @@ func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []he
|
||||
if s.sonarr == nil || s.store == nil {
|
||||
return nil
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
location := s.householdLocation()
|
||||
dayStart := localDayStart(now.In(location), location)
|
||||
key := heroPremiereCachePrefix + dayStart.Format("2006-01-02")
|
||||
|
||||
|
||||
@@ -97,20 +97,61 @@ func heroScheduleTimeZone(location *time.Location) string {
|
||||
return location.String()
|
||||
}
|
||||
|
||||
// heroSearchLimit is what either half of the picker's search may contribute, and what the
|
||||
// merged answer is trimmed back to.
|
||||
const heroSearchLimit = 20
|
||||
|
||||
// handleAdminHeroSearch answers the picker from the imported catalogue and from Emby.
|
||||
//
|
||||
// The catalogue alone is up to a sync interval stale, so a film imported this afternoon
|
||||
// was simply not findable here until the next hourly pass — and pinning is the one hero
|
||||
// decision an operator makes about a title *because* it has just arrived. Emby is asked
|
||||
// as well and `Find` imports what it returns, which is what makes a fresh id usable by the
|
||||
// policy validation and by the hero row itself rather than only by this list.
|
||||
//
|
||||
// Either half may fail without failing the search: a stale answer and a live one are both
|
||||
// better than an error, and the two are deliberately asked in that order so a gateway with
|
||||
// no Emby credentials configured still has a picker.
|
||||
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
|
||||
term := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
items, err := s.store.SearchLibrary(r.Context(), term, heroSearchLimit)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not search the library")
|
||||
return
|
||||
}
|
||||
results := make([]heroAdminItem, 0, len(items))
|
||||
for _, raw := range items {
|
||||
if item, ok := adminHeroItem(raw); ok {
|
||||
results = append(results, item)
|
||||
if s.syncer != nil && term != "" {
|
||||
found, findErr := s.syncer.Find(r.Context(), term, heroSearchLimit)
|
||||
if findErr != nil {
|
||||
s.loggerFor(r.Context()).Warn("hero live search unavailable", "error", findErr)
|
||||
}
|
||||
items = append(items, found...)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": results})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": mergeHeroSearchResults(items, heroSearchLimit)})
|
||||
}
|
||||
|
||||
// mergeHeroSearchResults turns both halves of the search into one list.
|
||||
//
|
||||
// Almost every title comes back from both, so the dedupe is the ordinary case rather than
|
||||
// the exception, and it keeps the first sighting: the catalogue answers first, and its
|
||||
// ranking is the one an operator has been reading all along. Anything an id appears in
|
||||
// only once is either a title Emby has and the last import missed — the whole point — or
|
||||
// one deleted from Emby that the catalogue has not swept yet.
|
||||
func mergeHeroSearchResults(items []json.RawMessage, limit int) []heroAdminItem {
|
||||
results := make([]heroAdminItem, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, raw := range items {
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
item, ok := adminHeroItem(raw)
|
||||
if !ok || seen[item.ID] {
|
||||
continue
|
||||
}
|
||||
seen[item.ID] = true
|
||||
results = append(results, item)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func heroSearchPayload(id, name, itemType string) json.RawMessage {
|
||||
return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType + `"}`)
|
||||
}
|
||||
|
||||
// The picker asks two sources that mostly agree, so one title arriving twice is the
|
||||
// ordinary case rather than a fault, and the catalogue's ranking is the one kept.
|
||||
func TestHeroSearchMergeKeepsTheFirstSighting(t *testing.T) {
|
||||
merged := mergeHeroSearchResults([]json.RawMessage{
|
||||
heroSearchPayload("1", "Arrival", "Movie"),
|
||||
heroSearchPayload("2", "Severance", "Series"),
|
||||
heroSearchPayload("1", "Arrival", "Movie"),
|
||||
}, 20)
|
||||
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged %d titles, want 2", len(merged))
|
||||
}
|
||||
if merged[0].ID != "1" || merged[1].ID != "2" {
|
||||
t.Fatalf("merge reordered the catalogue's answer: %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole reason Emby is asked: a title imported since the last pass is in one half of
|
||||
// the answer only, and it must survive into the list an operator can pin from.
|
||||
func TestHeroSearchMergeAdoptsATitleTheCatalogueMissed(t *testing.T) {
|
||||
merged := mergeHeroSearchResults([]json.RawMessage{
|
||||
heroSearchPayload("1", "Arrival", "Movie"),
|
||||
heroSearchPayload("1", "Arrival", "Movie"),
|
||||
heroSearchPayload("9", "Arrival of the Birds", "Movie"),
|
||||
}, 20)
|
||||
|
||||
if len(merged) != 2 || merged[1].ID != "9" {
|
||||
t.Fatalf("a title only Emby knew about was dropped: %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// A hero is a film or a show. An episode matching the term, and a synthetic schedule card
|
||||
// that cannot be played at all, are both things the picker must not offer.
|
||||
func TestHeroSearchMergeOffersOnlyPinnableTitles(t *testing.T) {
|
||||
merged := mergeHeroSearchResults([]json.RawMessage{
|
||||
heroSearchPayload("1", "Severance S01E01", "Episode"),
|
||||
json.RawMessage(`{"Id":"2","Name":"Dune","Type":"Movie","MembySource":"radarr"}`),
|
||||
heroSearchPayload("3", "Dune", "Movie"),
|
||||
}, 20)
|
||||
|
||||
if len(merged) != 1 || merged[0].ID != "3" {
|
||||
t.Fatalf("picker offered something unpinnable: %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroSearchMergeTrimsToTheLimit(t *testing.T) {
|
||||
items := make([]json.RawMessage, 0, 30)
|
||||
for index := range 30 {
|
||||
items = append(items, heroSearchPayload(string(rune('a'+index)), "Title", "Movie"))
|
||||
}
|
||||
|
||||
if merged := mergeHeroSearchResults(items, 20); len(merged) != 20 {
|
||||
t.Fatalf("merged %d titles, want the limit of 20", len(merged))
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ type homeResponse struct {
|
||||
// handleHome answers the entire launcher in one round trip.
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
s.noteDailyFirstUse(ctx, sess)
|
||||
limit := queryInt(r, "limit", 24, 100)
|
||||
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
|
||||
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
||||
@@ -203,10 +204,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
location := s.householdLocation()
|
||||
window := homeForYouWindowAt(time.Now().In(location))
|
||||
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
|
||||
if err != nil {
|
||||
|
||||
@@ -51,6 +51,20 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
|
||||
},
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "device-activity-cleanup",
|
||||
Name: "Device activity cleanup",
|
||||
Group: "Housekeeping",
|
||||
Description: fmt.Sprintf(
|
||||
"Removes the daily first-use marks that are older than %d days.",
|
||||
int(store.DeviceActivityRetention/(24*time.Hour))),
|
||||
Interval: 24 * time.Hour,
|
||||
Run: func(ctx context.Context) (string, error) {
|
||||
removed, err := s.store.PruneDeviceActivityDays(ctx, store.DeviceActivityRetention)
|
||||
return countDetail(removed, "activity mark"), err
|
||||
},
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "integration-cleanup",
|
||||
Name: "Integration delivery cleanup",
|
||||
@@ -103,10 +117,13 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
|
||||
Group: "Housekeeping",
|
||||
Description: fmt.Sprintf(
|
||||
"Retires gateway tokens unused for %d days, along with the Emby token each one holds.",
|
||||
int(s.cfg.SessionIdleExpiry/(24*time.Hour))),
|
||||
int(s.sessionIdleExpiry()/(24*time.Hour))),
|
||||
Interval: 6 * time.Hour,
|
||||
Run: func(ctx context.Context) (string, error) {
|
||||
removed, err := s.store.DeleteIdleSessions(ctx, s.cfg.SessionIdleExpiry)
|
||||
// Read at run time rather than closed over: the description above is written
|
||||
// once when the task is registered, but the sweep itself must follow the
|
||||
// operator's setting without a restart.
|
||||
removed, err := s.store.DeleteIdleSessions(ctx, s.sessionIdleExpiry())
|
||||
return countDetail(removed, "idle session"), err
|
||||
},
|
||||
})
|
||||
|
||||
@@ -87,10 +87,7 @@ func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
|
||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return parsed
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
location := s.householdLocation()
|
||||
parsed, err := time.ParseInLocation("2006-01-02", raw, location)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
@@ -105,10 +102,7 @@ func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
|
||||
// asked to do the grouping in it rather than the console doing it in the browser's zone,
|
||||
// for the reason store.LoginDays gives.
|
||||
func (s *Server) zoneName() string {
|
||||
if s.cfg.SonarrLocation != nil {
|
||||
return s.cfg.SonarrLocation.String()
|
||||
}
|
||||
return "UTC"
|
||||
return s.householdTimezoneName()
|
||||
}
|
||||
|
||||
type adminLoginsResponse struct {
|
||||
|
||||
@@ -13,9 +13,23 @@ import (
|
||||
)
|
||||
|
||||
const radarrCalendarCachePrefix = "radarr:calendar:v3:"
|
||||
const radarrScheduleDays = 5
|
||||
|
||||
// radarrScheduleDays is a month where the Sonarr schedule row's is a week, because films
|
||||
// and episodes arrive at quite different rates. A household's Sonarr calendar fills a week
|
||||
// several times over; its Radarr calendar, measured against the real catalogue, yields
|
||||
// about one title a fortnight — at five days the row was empty or held a single card most
|
||||
// of the time, which reads as a broken shelf rather than a quiet month.
|
||||
const radarrScheduleDays = 30
|
||||
|
||||
// radarrTheatricalDelayDays is the median cinema-to-digital gap for recent releases, used
|
||||
// only to estimate a digital date Radarr has not published. It shares a figure with
|
||||
// radarrScheduleDays by coincidence, not by meaning — they are free to move apart.
|
||||
const radarrTheatricalDelayDays = 30
|
||||
|
||||
// radarrWeekdayLabelDays is how far out a bare weekday still names an unambiguous day.
|
||||
// Beyond it the label carries a date, or a release five weeks away reads as this Friday.
|
||||
const radarrWeekdayLabelDays = 6
|
||||
|
||||
type radarrRelease struct {
|
||||
at time.Time
|
||||
estimated bool
|
||||
@@ -185,7 +199,7 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti
|
||||
}
|
||||
localRelease := release.at.In(location)
|
||||
item.MembyAirsAt = localRelease.Format(time.RFC3339)
|
||||
item.MembyAirDayLabel = scheduleAirDayLabel(localRelease, now, location)
|
||||
item.MembyAirDayLabel = radarrReleaseDayLabel(localRelease, now, location)
|
||||
item.MembyAirLabel = digitalReleaseLabel(localRelease, now, location, release.estimated)
|
||||
switch {
|
||||
case movie.HasFile:
|
||||
@@ -205,11 +219,27 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti
|
||||
return item
|
||||
}
|
||||
|
||||
// radarrReleaseDayLabel is the card's own day chip. It is Radarr's rather than
|
||||
// scheduleAirDayLabel because that one answers for a seven-day Sonarr window, where every
|
||||
// date it can be handed is inside the coming week and a weekday is never ambiguous.
|
||||
func radarrReleaseDayLabel(release, now time.Time, location *time.Location) string {
|
||||
today := localDayStart(now.In(location), location)
|
||||
releaseDay := localDayStart(release.In(location), location)
|
||||
switch {
|
||||
case releaseDay.Equal(today):
|
||||
return "Today"
|
||||
case releaseDay.Equal(today.AddDate(0, 0, 1)):
|
||||
return "Tomorrow"
|
||||
case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)):
|
||||
return releaseDay.Format("Monday")
|
||||
default:
|
||||
return releaseDay.Format("2 Jan")
|
||||
}
|
||||
}
|
||||
|
||||
func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string {
|
||||
release = release.In(location)
|
||||
now = now.In(location)
|
||||
today := localDayStart(now, location)
|
||||
releaseDay := localDayStart(release, location)
|
||||
today := localDayStart(now.In(location), location)
|
||||
releaseDay := localDayStart(release.In(location), location)
|
||||
prefix := "Digital release "
|
||||
if estimated {
|
||||
prefix = "Estimated digital release "
|
||||
@@ -219,8 +249,10 @@ func digitalReleaseLabel(release, now time.Time, location *time.Location, estima
|
||||
return prefix + "today"
|
||||
case releaseDay.Equal(today.AddDate(0, 0, 1)):
|
||||
return prefix + "tomorrow"
|
||||
case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)):
|
||||
return prefix + releaseDay.Format("Monday")
|
||||
default:
|
||||
return prefix + release.Format("Monday")
|
||||
return prefix + releaseDay.Format("2 January")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,12 +73,13 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
|
||||
return
|
||||
}
|
||||
if s.cfg.RadarrAlertWindow <= 0 {
|
||||
window := s.radarrAlertWindow()
|
||||
if window <= 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
|
||||
return
|
||||
}
|
||||
|
||||
s.publishAlert(r.Context(), alert, s.cfg.RadarrAlertWindow)
|
||||
s.publishAlert(r.Context(), alert, window)
|
||||
s.loggerFor(r.Context()).Info("radarr import announced",
|
||||
"movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true})
|
||||
|
||||
@@ -8,50 +8,91 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
)
|
||||
|
||||
func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInFiveDayWindow(t *testing.T) {
|
||||
func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInMonthWindow(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 30, 9, 0, 0, 0, location)
|
||||
digitalToday := time.Date(2026, 7, 30, 0, 0, 0, 0, location).UTC()
|
||||
digitalSunday := time.Date(2026, 8, 2, 0, 0, 0, 0, location).UTC()
|
||||
outside := time.Date(2026, 8, 4, 0, 0, 0, 0, location).UTC()
|
||||
theatricalOnly := time.Date(2026, 7, 1, 0, 0, 0, 0, location).UTC()
|
||||
oldDigital := time.Date(1993, 4, 9, 0, 0, 0, 0, location).UTC()
|
||||
modernRerelease := time.Date(2026, 7, 2, 0, 0, 0, 0, location).UTC()
|
||||
day := func(y int, m time.Month, d int) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, location).UTC()
|
||||
}
|
||||
digitalToday := day(2026, 7, 30)
|
||||
digitalSunday := day(2026, 8, 2)
|
||||
// Three weeks out: inside the month window, past the point a weekday still names a day.
|
||||
digitalLater := day(2026, 8, 20)
|
||||
// The day the window closes, which is exclusive.
|
||||
beyondWindow := day(2026, 8, 29)
|
||||
theatricalOnly := day(2026, 7, 1)
|
||||
oldDigital := day(1993, 4, 9)
|
||||
modernRerelease := day(2026, 7, 2)
|
||||
|
||||
row, err := buildRadarrRow([]radarr.Movie{
|
||||
{ID: 1, Title: "Today", DigitalRelease: &digitalToday, Monitored: true},
|
||||
{ID: 2, Title: "Sunday", DigitalRelease: &digitalSunday, Monitored: true},
|
||||
{ID: 3, Title: "Outside", DigitalRelease: &outside, Monitored: true},
|
||||
{ID: 3, Title: "Three Weeks Out", DigitalRelease: &digitalLater, Monitored: true},
|
||||
{ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true},
|
||||
{ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true},
|
||||
{ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true},
|
||||
}, now, location)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.ID != "radarr-upcoming-movies" || row.Kind != "movie-schedule" ||
|
||||
row.Title != "Upcoming Movie releases" || len(row.Items) != 3 {
|
||||
row.Title != "Upcoming Movie releases" || len(row.Items) != 4 {
|
||||
t.Fatalf("unexpected row: %+v", row)
|
||||
}
|
||||
var first, second radarrScheduleItem
|
||||
if err := json.Unmarshal(row.Items[0], &first); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
items := make([]radarrScheduleItem, len(row.Items))
|
||||
for i, raw := range row.Items {
|
||||
if err := json.Unmarshal(raw, &items[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(row.Items[1], &second); err != nil {
|
||||
t.Fatal(err)
|
||||
want := []struct {
|
||||
id, airLabel, dayLabel string
|
||||
}{
|
||||
{"radarr:1", "Digital release today", "Today"},
|
||||
{"radarr:4", "Estimated digital release tomorrow", "Tomorrow"},
|
||||
{"radarr:2", "Digital release Sunday", "Sunday"},
|
||||
{"radarr:3", "Digital release 20 August", "20 Aug"},
|
||||
}
|
||||
if first.ID != "radarr:1" || first.MembyAirLabel != "Digital release today" {
|
||||
t.Fatalf("unexpected first item: %+v", first)
|
||||
for i, expect := range want {
|
||||
got := items[i]
|
||||
if got.ID != expect.id || got.MembyAirLabel != expect.airLabel ||
|
||||
got.MembyAirDayLabel != expect.dayLabel {
|
||||
t.Fatalf("item %d: got id=%s air=%q day=%q, want id=%s air=%q day=%q",
|
||||
i, got.ID, got.MembyAirLabel, got.MembyAirDayLabel,
|
||||
expect.id, expect.airLabel, expect.dayLabel)
|
||||
}
|
||||
}
|
||||
if second.ID != "radarr:4" || second.MembyAirLabel != "Estimated digital release tomorrow" ||
|
||||
second.MembyAvailabilityText != "Estimated digital release" {
|
||||
t.Fatalf("unexpected second item: %+v", second)
|
||||
if items[1].MembyAvailabilityText != "Estimated digital release" {
|
||||
t.Fatalf("unexpected estimated availability: %+v", items[1])
|
||||
}
|
||||
var third radarrScheduleItem
|
||||
if err := json.Unmarshal(row.Items[2], &third); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A weekday only names an unambiguous day inside the coming week. The month-long window
|
||||
// routinely holds dates past that, where "Friday" would read as this Friday.
|
||||
func TestRadarrReleaseLabelsCarryADateBeyondTheComingWeek(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 30, 9, 0, 0, 0, location)
|
||||
cases := []struct {
|
||||
offset int
|
||||
dayLabel string
|
||||
airLabel string
|
||||
}{
|
||||
{0, "Today", "Digital release today"},
|
||||
{1, "Tomorrow", "Digital release tomorrow"},
|
||||
{2, "Saturday", "Digital release Saturday"},
|
||||
{radarrWeekdayLabelDays, "Wednesday", "Digital release Wednesday"},
|
||||
{radarrWeekdayLabelDays + 1, "6 Aug", "Digital release 6 August"},
|
||||
{21, "20 Aug", "Digital release 20 August"},
|
||||
}
|
||||
if third.ID != "radarr:2" || third.MembyAirLabel != "Digital release Sunday" {
|
||||
t.Fatalf("unexpected third item: %+v", third)
|
||||
for _, tc := range cases {
|
||||
release := now.AddDate(0, 0, tc.offset)
|
||||
if got := radarrReleaseDayLabel(release, now, location); got != tc.dayLabel {
|
||||
t.Errorf("offset %d: day label = %q, want %q", tc.offset, got, tc.dayLabel)
|
||||
}
|
||||
if got := digitalReleaseLabel(release, now, location, false); got != tc.airLabel {
|
||||
t.Errorf("offset %d: air label = %q, want %q", tc.offset, got, tc.airLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ func (s *Server) personalizeTitles(
|
||||
profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
|
||||
cfg := s.weightedConfig()
|
||||
now := time.Now()
|
||||
location := s.cfg.SonarrLocation
|
||||
location := s.householdLocation()
|
||||
for index := range rows {
|
||||
row := &rows[index]
|
||||
if progressRow(row.ID) {
|
||||
|
||||
@@ -112,27 +112,34 @@ func librarySyncTitle(changed int) string {
|
||||
//
|
||||
// Only *transitions* are announced. A server that is down stays down, and repeating it
|
||||
// every minute would bury everything else.
|
||||
func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// The cadence is read on every tick rather than fixed at start-up, because it is an
|
||||
// operator setting the console can change: a loop that captured it would leave the one
|
||||
// probe an operator is most likely to want to slow down or switch off needing a restart
|
||||
// of the gateway to do either. A probe that is off still ticks, slowly, so that turning
|
||||
// it back on does not need one either.
|
||||
func (s *Server) WatchEmbyReachability(ctx context.Context) {
|
||||
// Start out assuming reachable: a gateway booting while Emby is down should not
|
||||
// open with a banner about a state nobody has seen change.
|
||||
reachable := true
|
||||
failures := 0
|
||||
interval := s.embyHealthInterval()
|
||||
// The same probe feeds the live state on /v1/status, so one request to Emby answers
|
||||
// both "did this just change" and "is it working right now". Declared before the
|
||||
// first tick so a client asking during the opening minute learns the retry interval.
|
||||
s.embyHealth.begin(interval, time.Now().UTC())
|
||||
s.embyHealth.retune(interval, time.Now().UTC())
|
||||
timer := time.NewTimer(embyProbeDelay(interval))
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.quietTimeActive() {
|
||||
case <-timer.C:
|
||||
if next := s.embyHealthInterval(); next != interval {
|
||||
interval = next
|
||||
s.embyHealth.retune(interval, time.Now().UTC())
|
||||
}
|
||||
timer.Reset(embyProbeDelay(interval))
|
||||
if interval <= 0 || s.quietTimeActive() {
|
||||
continue
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
@@ -160,6 +167,16 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
|
||||
}
|
||||
}
|
||||
|
||||
// embyProbeDelay is how long to wait before looking again. With the probe switched off it
|
||||
// is the interval at which the loop asks whether it has been switched back on, which is a
|
||||
// minute because nothing is watching and nothing is being asked of Emby.
|
||||
func embyProbeDelay(interval time.Duration) time.Duration {
|
||||
if interval <= 0 {
|
||||
return time.Minute
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
// reachabilityAlert is timestamped per transition, so the "back online" banner never
|
||||
// collides with the "not responding" one it replaces.
|
||||
func (s *Server) reachabilityAlert(up bool) clientAlert {
|
||||
|
||||
Reference in New Issue
Block a user