0.2.77
This commit is contained in:
@@ -88,6 +88,11 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("GET /admin/api/logins/devices", s.adminAuth(s.handleAdminLoginDevices))
|
||||
mux.Handle("GET /admin/api/logins/devices/{deviceID}", s.adminAuth(s.handleAdminDeviceDetail))
|
||||
|
||||
// The outbound notification history. Distinct from the feed below it: that is the
|
||||
// operator's own activity bell, this is the record of what Memby sent to viewers and
|
||||
// to external services.
|
||||
mux.Handle("GET /admin/api/notification-log", s.adminAuth(s.handleAdminNotificationLog))
|
||||
|
||||
// The administrative feed behind the notification bell.
|
||||
mux.Handle("GET /admin/api/notifications", s.adminAuth(s.handleAdminNotifications))
|
||||
mux.Handle("POST /admin/api/notifications/read", s.adminAuth(s.handleAdminNotificationsRead))
|
||||
|
||||
@@ -32,9 +32,13 @@ type adminOnboardingPreferences struct {
|
||||
}
|
||||
|
||||
type adminMembyAccount struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Initials string `json:"initials"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Initials string `json:"initials"`
|
||||
// ShortName is the friendly name the launcher greets this person by, and is blank far
|
||||
// more often than not — the directory reads it as "their account name" rather than as
|
||||
// something missing.
|
||||
ShortName string `json:"shortName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Devices []store.MembyDevice `json:"devices"`
|
||||
@@ -153,8 +157,9 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
result = append(result, adminMembyAccount{
|
||||
WatchTime: summariseWatchTime(watched, matchedWatchTime),
|
||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
|
||||
ShortName: stringPreference(accountSettings.Preferences, "shortName"),
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Notifications: notificationPrefs,
|
||||
Recommendations: adminOnboardingPreferences{
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The console's window on the outbound notification log.
|
||||
//
|
||||
// One route rather than three, unlike the sign-in history: an operator arrives here with a
|
||||
// *question* — "did the weekly summary go out", "why did nobody get told about that
|
||||
// import" — and every part of the answer is the same filtered window. Splitting the table
|
||||
// from its totals would mean two requests that could disagree with each other while a
|
||||
// filter was being typed.
|
||||
const (
|
||||
// notificationPageLimit caps one page. Large enough that the ordinary answer needs no
|
||||
// paging, small enough that a household with a busy week does not send a megabyte.
|
||||
notificationPageLimit = 100
|
||||
// notificationWindowDays is the widest window the page offers, derived from the
|
||||
// retention period rather than written down: PruneNotificationLog removes anything
|
||||
// older, so a page offering 180 days would draw a flat line for half of it.
|
||||
notificationWindowDays = int(store.NotificationRetention / (24 * time.Hour))
|
||||
)
|
||||
|
||||
type adminNotificationLogResponse struct {
|
||||
Entries []store.NotificationLogEntry `json:"entries"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Totals store.NotificationLogTotals `json:"totals"`
|
||||
Days []store.NotificationLogDay `json:"days"`
|
||||
Facets store.NotificationFacets `json:"facets"`
|
||||
Users []store.KnownUser `json:"users"`
|
||||
Retention int `json:"retentionDays"`
|
||||
}
|
||||
|
||||
// notificationLogFilter reads the console's question off the query string.
|
||||
//
|
||||
// Every list filter is comma-separated and multi-valued, because the useful questions are
|
||||
// plural: "everything that failed or was skipped", "both digest kinds". A single-valued
|
||||
// filter would make the common troubleshooting question take two passes.
|
||||
func notificationLogFilter(r *http.Request) store.NotificationLogFilter {
|
||||
query := r.URL.Query()
|
||||
filter := store.NotificationLogFilter{
|
||||
UserID: strings.TrimSpace(query.Get("user")),
|
||||
Kinds: splitCSV(query.Get("kind")),
|
||||
Channels: splitCSV(query.Get("channel")),
|
||||
Statuses: splitCSV(query.Get("status")),
|
||||
Sources: splitCSV(query.Get("source")),
|
||||
Query: strings.TrimSpace(query.Get("q")),
|
||||
Limit: queryInt(r, "limit", notificationPageLimit, 500),
|
||||
Offset: queryInt(r, "offset", 0, 100000),
|
||||
}
|
||||
filter.From, filter.To = notificationWindow(r)
|
||||
return filter
|
||||
}
|
||||
|
||||
// notificationWindow resolves the date range.
|
||||
//
|
||||
// An explicit `from` wins over the day window, the rule the sign-in history follows: an
|
||||
// operator who typed a date meant it, and silently narrowing it to the last week would
|
||||
// answer a question they did not ask. `to` is read as the *end* of the day named, because
|
||||
// somebody filtering "to the 12th" means through the 12th, not up to midnight at its start.
|
||||
func notificationWindow(r *http.Request) (time.Time, time.Time) {
|
||||
query := r.URL.Query()
|
||||
from := parseDay(query.Get("from"))
|
||||
to := parseDay(query.Get("to"))
|
||||
if !to.IsZero() {
|
||||
to = to.AddDate(0, 0, 1)
|
||||
}
|
||||
if from.IsZero() {
|
||||
days := queryInt(r, "days", 7, notificationWindowDays)
|
||||
if days > 0 {
|
||||
from = time.Now().UTC().AddDate(0, 0, -days)
|
||||
}
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
|
||||
func parseDay(raw string) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
day, err := time.Parse("2006-01-02", raw)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// handleAdminNotificationLog answers the Notifications page.
|
||||
//
|
||||
// The log is the page and everything else is decoration, which is why only its failure is
|
||||
// a 500: a facet list or a name lookup that will not answer costs a dropdown, and an
|
||||
// operator reading this page after something went wrong must still get the rows.
|
||||
func (s *Server) handleAdminNotificationLog(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
filter := notificationLogFilter(r)
|
||||
|
||||
page, err := s.store.NotificationLog(ctx, filter)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("notification log read failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read the notification history")
|
||||
return
|
||||
}
|
||||
|
||||
response := adminNotificationLogResponse{
|
||||
Entries: page.Entries, Total: page.Total, Limit: page.Limit, Offset: page.Offset,
|
||||
Days: []store.NotificationLogDay{}, Users: []store.KnownUser{},
|
||||
Retention: notificationWindowDays,
|
||||
}
|
||||
if totals, err := s.store.NotificationLogTotals(ctx, filter); err == nil {
|
||||
response.Totals = totals
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("notification totals unavailable", "error", err)
|
||||
}
|
||||
if days, err := s.store.NotificationLogDays(ctx, filter); err == nil {
|
||||
response.Days = days
|
||||
}
|
||||
// Facets are computed over the whole retention window rather than the current filter,
|
||||
// so narrowing the table never removes the option that would widen it again.
|
||||
since := time.Now().UTC().Add(-store.NotificationRetention)
|
||||
if facets, err := s.store.NotificationLogFacets(ctx, since); err == nil {
|
||||
response.Facets = facets
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("notification facets unavailable", "error", err)
|
||||
}
|
||||
if users, err := s.store.KnownUsers(ctx); err == nil {
|
||||
response.Users = users
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
@@ -99,9 +99,25 @@ type storedAlert struct {
|
||||
// Failures are logged and swallowed. A missed banner is not worth failing the thing that
|
||||
// produced it — an import, a library sync, a health probe — none of which the viewer
|
||||
// would want retried for the sake of a notice.
|
||||
//
|
||||
// Producers no longer call this directly: they call Server.broadcast, which carries the
|
||||
// same alert through the notification service so it lands in the audit trail beside every
|
||||
// other thing Memby sent. This remains the delivery half of that path.
|
||||
func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window time.Duration) {
|
||||
if err := s.publishAlertNow(ctx, alert, window); err != nil {
|
||||
s.loggerFor(ctx).Warn("alert publish failed", "id", alert.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishAlertNow is publishAlert with the failure returned rather than swallowed.
|
||||
//
|
||||
// The notification log needs the error — a banner nobody received is exactly the row an
|
||||
// operator opens this feature to find — and swallowing it here would leave the audit trail
|
||||
// reporting a success the cache never gave. Everything above still treats the answer as
|
||||
// advisory; nothing retries on it.
|
||||
func (s *Server) publishAlertNow(ctx context.Context, alert clientAlert, window time.Duration) error {
|
||||
if window <= 0 || alert.ID == "" || s.cache == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
// Read-modify-write on one key, so producers running on their own schedules need
|
||||
// serialising against each other. They are rare enough that a mutex is the whole
|
||||
@@ -113,14 +129,14 @@ func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window tim
|
||||
stored := appendAlert(s.storedAlerts(ctx), alert, now.Add(window), now)
|
||||
body, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("alert encode failed", "error", err)
|
||||
return
|
||||
return fmt.Errorf("alert encode failed: %w", err)
|
||||
}
|
||||
// The key's own TTL is a floor sweep for a gateway that stops producing events; the
|
||||
// per-entry expiry is what actually decides what a client sees.
|
||||
if err := s.cache.Set(ctx, publishedAlertsCacheKey, body, window*2); err != nil {
|
||||
s.loggerFor(ctx).Warn("alert store failed", "error", err)
|
||||
return fmt.Errorf("alert store failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendAlert is the pure half of publishing: prune what has expired, replace any earlier
|
||||
|
||||
@@ -34,18 +34,18 @@ type analyticsRequest struct {
|
||||
}
|
||||
|
||||
type journeyEventPayload struct {
|
||||
UserID string `json:"userId"`
|
||||
JourneyID string `json:"journeyId"`
|
||||
Sequence int `json:"sequence"`
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
Screen string `json:"screen"`
|
||||
Feature string `json:"feature"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ItemID string `json:"itemId"`
|
||||
ItemName string `json:"itemName"`
|
||||
ItemType string `json:"itemType"`
|
||||
UserID string `json:"userId"`
|
||||
JourneyID string `json:"journeyId"`
|
||||
Sequence int `json:"sequence"`
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
Screen string `json:"screen"`
|
||||
Feature string `json:"feature"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ItemID string `json:"itemId"`
|
||||
ItemName string `json:"itemName"`
|
||||
ItemType string `json:"itemType"`
|
||||
// The Emby play session a playback step belongs to. Validated like every other
|
||||
// controlled field: it is Emby's string rather than ours, and an event carrying one this
|
||||
// cannot read is dropped whole, so the television sanitises it before sending.
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/integrations"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
@@ -71,7 +72,11 @@ type Server struct {
|
||||
adminEvents *adminevents.Bus
|
||||
scheduler *scheduler.Scheduler
|
||||
integrations *integrations.Dispatcher
|
||||
sonarrMu sync.Mutex
|
||||
// notify is the one door every outbound notification leaves through, and the only
|
||||
// thing that writes the notification log. Producers never call a delivery provider
|
||||
// directly any more — see internal/api/notifications.go.
|
||||
notify *notify.Service
|
||||
sonarrMu sync.Mutex
|
||||
// sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add
|
||||
// to My Shows never waits behind a launcher rebuilding the schedule row.
|
||||
sonarrSeriesMu sync.Mutex
|
||||
@@ -143,6 +148,10 @@ type Deps struct {
|
||||
AdminEvents *adminevents.Bus
|
||||
Scheduler *scheduler.Scheduler
|
||||
Integrations *integrations.Dispatcher
|
||||
// Notify is optional. A server built without one still delivers every notification —
|
||||
// Send falls through to the providers regardless — it simply records nothing, which is
|
||||
// what every unit test in this package wants.
|
||||
Notify *notify.Service
|
||||
// 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.
|
||||
@@ -150,7 +159,7 @@ type Deps struct {
|
||||
}
|
||||
|
||||
func New(cfg config.Config, deps Deps) *Server {
|
||||
return &Server{
|
||||
server := &Server{
|
||||
cfg: cfg,
|
||||
emby: deps.Emby,
|
||||
store: deps.Store,
|
||||
@@ -172,9 +181,16 @@ func New(cfg config.Config, deps Deps) *Server {
|
||||
scheduler: deps.Scheduler,
|
||||
integrations: deps.Integrations,
|
||||
|
||||
notify: deps.Notify,
|
||||
|
||||
logLevel: deps.LogLevel,
|
||||
deployedLogLevel: deployedLevel(deps.LogLevel),
|
||||
}
|
||||
// The providers are installed here rather than by the caller so a producer can assume
|
||||
// the channels it uses exist: a channel with no provider is a configuration fault the
|
||||
// audit trail would faithfully record on every single notification.
|
||||
server.registerNotifiers()
|
||||
return server
|
||||
}
|
||||
|
||||
func deployedLevel(level *slog.LevelVar) slog.Level {
|
||||
@@ -292,6 +308,7 @@ func (s *Server) Routes() http.Handler {
|
||||
// token arrives in the query string, the way artwork's does, because a media player
|
||||
// fetching a sidecar sends none of Memby's headers.
|
||||
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle))
|
||||
v1.Handle("GET /v1/radarr/movies/{id}", s.authed(s.handleRadarrMovie))
|
||||
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
|
||||
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
|
||||
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
|
||||
|
||||
@@ -142,7 +142,7 @@ var featureCatalogue = []featureDefinition{
|
||||
Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications",
|
||||
Description: "Tell each viewer how long they watched this week and this month, on " +
|
||||
"Sunday evening, with a summary of the month just gone once it ends. Read from " +
|
||||
"Tracearr; a household running none never sends one.",
|
||||
"Tracearr; a server running none never sends one.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1,
|
||||
Recovery: "Server-enforced; takes effect before the next summary is due.",
|
||||
},
|
||||
|
||||
@@ -51,6 +51,22 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
|
||||
},
|
||||
})
|
||||
|
||||
// The outbound notification history, which is a different table from the one above:
|
||||
// that prunes the operator's activity feed, this prunes the record of what Memby sent
|
||||
// to viewers and to external services.
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "notification-log-retention",
|
||||
Name: "Notification history retention",
|
||||
Group: "Housekeeping",
|
||||
Description: fmt.Sprintf("Removes outbound notification records older than %d days.",
|
||||
int(store.NotificationRetention/(24*time.Hour))),
|
||||
Interval: 24 * time.Hour,
|
||||
Run: func(ctx context.Context) (string, error) {
|
||||
removed, err := s.store.PruneNotificationLog(ctx, store.NotificationRetention)
|
||||
return countDetail(removed, "notification record"), err
|
||||
},
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "device-activity-cleanup",
|
||||
Name: "Device activity cleanup",
|
||||
|
||||
@@ -57,10 +57,14 @@ func (s *Server) AnnounceLibraryIngest(ctx context.Context, result library.Inges
|
||||
// episode. Neither is a title somebody can watch, and the episode that follows is.
|
||||
}
|
||||
|
||||
// The zero-window case is deliberately not short-circuited here any more. An operator who
|
||||
// has switched movie import banners off is a reason nobody was told, and the notification
|
||||
// log is where that answer belongs — deliverBroadcast records it as a skip rather than the
|
||||
// producer returning in silence.
|
||||
func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) {
|
||||
window := s.radarrAlertWindow()
|
||||
title := strings.TrimSpace(result.Name)
|
||||
if window <= 0 || title == "" || result.ItemID == "" {
|
||||
if title == "" || result.ItemID == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
@@ -68,7 +72,7 @@ func (s *Server) announceImportedMovie(ctx context.Context, result library.Inges
|
||||
if result.Year > 0 {
|
||||
name = fmt.Sprintf("%s (%d)", title, result.Year)
|
||||
}
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibraryIngest, clientAlert{
|
||||
// Keyed on the Emby item, so a repeated delivery of one import is one banner while
|
||||
// a film deleted and re-imported is news again. Clients dedupe on this id forever.
|
||||
ID: "ingest:movie:" + result.ItemID,
|
||||
@@ -90,7 +94,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing
|
||||
// as it does the "aired, coming soon" one, without touching films.
|
||||
window := s.sonarrAlertWindow()
|
||||
series := strings.TrimSpace(result.SeriesName)
|
||||
if window <= 0 || series == "" || result.ItemID == "" {
|
||||
if series == "" || result.ItemID == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
@@ -98,7 +102,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing
|
||||
seasonRunKey(series, result.Season), result.ItemID,
|
||||
episodeSummary(result), now, ingestRunWindow,
|
||||
)
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibraryIngest, clientAlert{
|
||||
// The run's *first* episode anchors the id, so every later arrival in the same
|
||||
// season pack replaces one banner rather than stacking another — and next week's
|
||||
// episode, arriving after the window has closed, starts a run of its own and is
|
||||
|
||||
@@ -84,7 +84,7 @@ func buildSonarrRowForTest(t *testing.T, episode sonarr.Episode, now time.Time)
|
||||
|
||||
func buildRadarrRowForTest(t *testing.T, movie radarr.Movie, now time.Time) radarrScheduleItem {
|
||||
t.Helper()
|
||||
row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC)
|
||||
row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRadarrRow: %v", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -213,10 +214,18 @@ func (s *Server) syncReturnNotifications(
|
||||
message = show.Title + " returns next week."
|
||||
}
|
||||
sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02")
|
||||
_ = s.store.UpsertNotification(
|
||||
r.Context(), sess.EmbyUserID, sourceKey, "show-return", show.ItemID,
|
||||
"New episode coming", message, series.NextAiring,
|
||||
)
|
||||
s.notifyUser(r.Context(), notify.Notification{
|
||||
Kind: "show-return",
|
||||
Source: notifySourceShowReturn,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
Title: "New episode coming",
|
||||
Body: message,
|
||||
ItemID: show.ItemID,
|
||||
SourceKey: sourceKey,
|
||||
EventAt: series.NextAiring,
|
||||
Metadata: map[string]any{"show": show.Title, "leadDays": prefs.LeadDays},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +240,12 @@ func (s *Server) handleNotificationAction(
|
||||
switch r.PathValue("action") {
|
||||
case "read":
|
||||
err = s.store.MarkNotificationRead(r.Context(), sess.EmbyUserID, id)
|
||||
// Marking a notification back to new is the viewer's own action, where "read" is set by
|
||||
// the page merely focusing a row. That is why the two are separate routes rather than one
|
||||
// carrying a boolean: an automatic mark and a deliberate one are different events, and only
|
||||
// this one is ever a decision somebody made with the remote.
|
||||
case "unread":
|
||||
err = s.store.MarkNotificationUnread(r.Context(), sess.EmbyUserID, id)
|
||||
case "dismiss":
|
||||
err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id)
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
)
|
||||
|
||||
// The gateway's notification providers, and the two helpers every producer now calls.
|
||||
//
|
||||
// internal/notify owns the audit trail and knows nothing about Memby's channels; this file
|
||||
// is the other half — what "in-app" and "broadcast" actually mean here. A feature says
|
||||
// *what* it wants said; these decide how it is carried and what to report about it.
|
||||
//
|
||||
// The sources below name the service that decided to notify. They are stored and the
|
||||
// console filters on them, so they are constants rather than string literals typed at each
|
||||
// call site: a source spelled two ways is two rows in a dropdown for one feature.
|
||||
const (
|
||||
notifySourceSonarrLifecycle = "sonarr-lifecycle"
|
||||
notifySourceShowReturn = "show-return-scan"
|
||||
notifySourceAutoFollow = "auto-follow"
|
||||
notifySourceWatchTime = "watch-time-digest"
|
||||
notifySourceLibraryIngest = "library-ingest"
|
||||
notifySourceLibrarySync = "library-sync"
|
||||
notifySourceDeployment = "deployment"
|
||||
notifySourceEmbyHealth = "emby-health"
|
||||
notifySourceIntegrations = "integrations"
|
||||
)
|
||||
|
||||
// registerNotifiers installs the gateway's delivery providers on the notification service.
|
||||
// Called once from New, so every producer can assume the channels it uses exist.
|
||||
func (s *Server) registerNotifiers() {
|
||||
if s.notify == nil {
|
||||
return
|
||||
}
|
||||
s.notify.Register(
|
||||
notify.DelivererFunc{Name: notify.ChannelInApp, Fn: s.deliverInApp},
|
||||
notify.DelivererFunc{Name: notify.ChannelBroadcast, Fn: s.deliverBroadcast},
|
||||
)
|
||||
}
|
||||
|
||||
// deliverInApp writes a notification into one viewer's own list.
|
||||
//
|
||||
// The three answers it can give are all real and all worth recording separately. A row was
|
||||
// written: sent. A row with that source key was already there: skipped, because the
|
||||
// producers here are deliberately re-run — the watch-time digest fires hourly and re-sends
|
||||
// the same weekly key all evening so a gateway that was off still delivers — and every one
|
||||
// of those catch-up passes would otherwise read as a summary somebody never got. And the
|
||||
// write failed: failed, with the database's own words, which is the only thing that would
|
||||
// explain a viewer's empty list.
|
||||
func (s *Server) deliverInApp(ctx context.Context, n notify.Notification) notify.Outcome {
|
||||
if s.store == nil {
|
||||
return notify.Failed(errors.New("no database"))
|
||||
}
|
||||
if n.UserID == "" {
|
||||
return notify.Failed(errors.New("an in-app notification needs a recipient"))
|
||||
}
|
||||
inserted, err := s.store.UpsertNotification(
|
||||
ctx, n.UserID, n.SourceKey, n.Kind, n.ItemID, n.Title, n.Body, n.EventAt)
|
||||
if err != nil {
|
||||
return notify.Failed(err)
|
||||
}
|
||||
if !inserted {
|
||||
return notify.Skipped("already in this viewer's list")
|
||||
}
|
||||
return notify.Sent()
|
||||
}
|
||||
|
||||
// broadcastWindow travels with a broadcast notification: how long the alert stays on offer
|
||||
// to televisions that were switched off when it happened.
|
||||
const broadcastWindowKey = "windowSeconds"
|
||||
|
||||
// deliverBroadcast publishes a service alert to every signed-in television.
|
||||
//
|
||||
// The alert itself is carried in the notification's metadata rather than in its fields,
|
||||
// because a clientAlert is a wire type with an id, a kind and an image tag that
|
||||
// notify.Notification has no business modelling. broadcast() below is the only thing that
|
||||
// builds one of these, so the round trip is contained.
|
||||
func (s *Server) deliverBroadcast(ctx context.Context, n notify.Notification) notify.Outcome {
|
||||
alert, ok := n.Metadata[broadcastAlertKey].(clientAlert)
|
||||
if !ok {
|
||||
return notify.Failed(errors.New("no alert to publish"))
|
||||
}
|
||||
window, _ := n.Metadata[broadcastWindowKey].(time.Duration)
|
||||
if window <= 0 {
|
||||
// An operator has this kind of news switched off. Deliberately a recorded skip
|
||||
// rather than silence: "the window is zero" is the answer to why nobody was told,
|
||||
// and it is not one anybody would find by reading the code.
|
||||
return notify.Skipped("this alert window is switched off")
|
||||
}
|
||||
if s.cache == nil {
|
||||
return notify.Failed(errors.New("no cache to publish alerts through"))
|
||||
}
|
||||
if err := s.publishAlertNow(ctx, alert, window); err != nil {
|
||||
return notify.Failed(err)
|
||||
}
|
||||
return notify.Sent()
|
||||
}
|
||||
|
||||
// broadcastAlertKey is the metadata slot the clientAlert rides in. It is stripped before
|
||||
// the record is written — the alert's fields are already the record's title and body, and
|
||||
// storing the whole struct again would put a second copy of every banner in the log.
|
||||
const broadcastAlertKey = "alert"
|
||||
|
||||
// broadcast is what every service-alert producer calls in place of publishAlert.
|
||||
//
|
||||
// It is the one place a clientAlert becomes a notification, so the console's row for a
|
||||
// banner says the same thing the television's bar said, with no producer having to
|
||||
// describe its news twice.
|
||||
func (s *Server) broadcast(
|
||||
ctx context.Context, source string, alert clientAlert, window time.Duration,
|
||||
) {
|
||||
outcome := s.notify.Send(ctx, notify.Notification{
|
||||
Channel: notify.ChannelBroadcast,
|
||||
Kind: alert.Kind,
|
||||
Source: source,
|
||||
Title: alert.Title,
|
||||
Body: alert.Message,
|
||||
ItemID: alert.ItemID,
|
||||
SourceKey: alert.ID,
|
||||
EventAt: alertEventTime(alert),
|
||||
Metadata: map[string]any{
|
||||
broadcastAlertKey: alert,
|
||||
broadcastWindowKey: window,
|
||||
"label": alert.Label,
|
||||
},
|
||||
})
|
||||
if outcome.Err != nil {
|
||||
s.loggerFor(ctx).Warn("service alert not published",
|
||||
"kind", alert.Kind, "id", alert.ID, "error", outcome.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func alertEventTime(alert clientAlert) *time.Time {
|
||||
when, err := time.Parse(time.RFC3339, alert.AiredAt)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &when
|
||||
}
|
||||
|
||||
// notifyUser is what every per-viewer producer calls in place of store.UpsertNotification.
|
||||
//
|
||||
// It returns whether the notification actually reached the viewer's list, which is what
|
||||
// the callers' own counters mean: the Sonarr scan reporting "14 notifications" must not
|
||||
// count fourteen repeats of one it had already sent.
|
||||
func (s *Server) notifyUser(ctx context.Context, n notify.Notification) bool {
|
||||
n.Channel = notify.ChannelInApp
|
||||
return s.notify.Send(ctx, n).Status == notify.StatusSent
|
||||
}
|
||||
|
||||
// declineUser records a notification a viewer's own preferences refused.
|
||||
//
|
||||
// This is the half a per-feature audit trail always misses, and it is the reason the page
|
||||
// is worth having: "I never got the weekly summary" and "you have weekly summaries turned
|
||||
// off" look identical from the outside, and only a recorded skip tells them apart. It is
|
||||
// never delivered, so it goes through Log rather than Send.
|
||||
func (s *Server) declineUser(ctx context.Context, n notify.Notification, reason string) {
|
||||
n.Channel = notify.ChannelInApp
|
||||
s.notify.Log(ctx, n, notify.Skipped(reason), 0)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -950,15 +951,30 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
|
||||
return ""
|
||||
}
|
||||
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
|
||||
_ = s.store.UpsertNotification(
|
||||
ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow",
|
||||
episode.SeriesID, "Added to My Shows",
|
||||
seriesItem.Name+" was added because you started watching it and it is still continuing.", nil,
|
||||
)
|
||||
return seriesItem.Name
|
||||
notification := notify.Notification{
|
||||
Kind: "auto-follow",
|
||||
Source: notifySourceAutoFollow,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
Title: "Added to My Shows",
|
||||
Body: seriesItem.Name + " was added because you started watching it and it is still continuing.",
|
||||
ItemID: episode.SeriesID,
|
||||
SourceKey: "auto-follow:" + episode.SeriesID,
|
||||
Metadata: map[string]any{"series": seriesItem.Name},
|
||||
}
|
||||
return ""
|
||||
// The show is followed either way — that is the feature — and only the *notice* is
|
||||
// conditional. Recording the refusal is what separates "Memby quietly followed this for
|
||||
// you" from a bug, which from the viewer's side look the same.
|
||||
if !prefs.Enabled {
|
||||
s.declineUser(ctx, notification, "this viewer has notifications switched off")
|
||||
return ""
|
||||
}
|
||||
if !s.featureEnabled(ctx, featureMyShowsNotification) {
|
||||
s.declineUser(ctx, notification, "the My Shows notification feature is switched off")
|
||||
return ""
|
||||
}
|
||||
s.notifyUser(ctx, notification)
|
||||
return seriesItem.Name
|
||||
}
|
||||
|
||||
func max64(v, floor int64) int64 {
|
||||
|
||||
@@ -61,8 +61,16 @@ type preferenceDefinition struct {
|
||||
// number reads as minutes, which is what the first one to exist happened to be.
|
||||
Unit string `json:"unit,omitempty"`
|
||||
MaxLength int `json:"maxLength,omitempty"`
|
||||
AdminOnly bool `json:"adminOnly,omitempty"`
|
||||
Default any `json:"default"`
|
||||
// Uppercase folds a text value to capitals. It belongs to the definition rather than
|
||||
// to the kind: initials are read as capitals, and a person's name is not — folding
|
||||
// every text setting would have the launcher greeting somebody as MATT.
|
||||
Uppercase bool `json:"uppercase,omitempty"`
|
||||
// Placeholder is what the console shows in an empty field, which for these settings is
|
||||
// what happens when nobody fills it in. Blank is a legal value for both of them, so the
|
||||
// field has to say what blank means or it reads as a setting that was never finished.
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
AdminOnly bool `json:"adminOnly,omitempty"`
|
||||
Default any `json:"default"`
|
||||
}
|
||||
|
||||
func option(value, label string) preferenceOption {
|
||||
@@ -74,6 +82,17 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
Key: "profileInitials", Name: "Profile initials", Area: "Profile",
|
||||
Description: "Up to two characters shown in this person's user-switcher avatar. Leave blank to generate them from their name.",
|
||||
Kind: preferenceText, Default: "", MaxLength: 2, AdminOnly: true,
|
||||
Uppercase: true, Placeholder: "Generated from their name",
|
||||
},
|
||||
{
|
||||
// The friendly name Memby addresses somebody by, and nothing more: it is not a
|
||||
// second username and nothing is keyed on it. Blank is the ordinary state — the
|
||||
// television falls back to the account name — so this is only worth setting where
|
||||
// the account name is not what anybody would call the person.
|
||||
Key: "shortName", Name: "Short name", Area: "Profile",
|
||||
Description: "The friendly name Memby greets this person by. Leave blank to use their account name.",
|
||||
Kind: preferenceText, Default: "", MaxLength: shortNameMaxLength, AdminOnly: true,
|
||||
Placeholder: "Their account name",
|
||||
},
|
||||
{
|
||||
Key: "homeSections", Name: "Home rows", Area: "Home",
|
||||
@@ -237,6 +256,10 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
},
|
||||
}
|
||||
|
||||
// shortNameMaxLength bounds the friendly name. It is a first name on a launcher, not a
|
||||
// field to write a sentence in, and the greeting it lands in shares its line with a clock.
|
||||
const shortNameMaxLength = 24
|
||||
|
||||
// maxListEntries bounds the free-form id lists. They come from a television, and a row
|
||||
// list long enough to matter is already a bug on that end.
|
||||
const maxListEntries = 200
|
||||
@@ -334,7 +357,10 @@ func normalizePreference(definition preferenceDefinition, value any) any {
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if !strings.ContainsAny(trimmed, "\n\r") &&
|
||||
(definition.MaxLength <= 0 || len([]rune(trimmed)) <= definition.MaxLength) {
|
||||
return strings.ToUpper(trimmed)
|
||||
if definition.Uppercase {
|
||||
return strings.ToUpper(trimmed)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,37 @@ func TestNormalizePreferencesBoundsAndNormalisesProfileInitials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A short name is a person's name, so unlike the initials beside it in the catalogue it
|
||||
// keeps the case it was typed in. Folding it would have the launcher greeting somebody as
|
||||
// MATT, which is the whole reason Uppercase is per-definition rather than per-kind.
|
||||
func TestNormalizePreferencesKeepsShortNameCaseAndBoundsIt(t *testing.T) {
|
||||
if got := normalizePreferences(map[string]any{"shortName": " Matt "})["shortName"]; got != "Matt" {
|
||||
t.Errorf("shortName = %v, want Matt", got)
|
||||
}
|
||||
long := strings.Repeat("a", shortNameMaxLength+1)
|
||||
for _, value := range []any{long, "Ma\ntt", 12} {
|
||||
if got := normalizePreferences(map[string]any{"shortName": value})["shortName"]; got != "" {
|
||||
t.Errorf("shortName for %v = %v, want the account-name fallback", value, got)
|
||||
}
|
||||
}
|
||||
if got := normalizePreferences(nil)["shortName"]; got != "" {
|
||||
t.Errorf("default shortName = %v, want blank", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The short name is admin-owned like the initials, so a television saving an unrelated
|
||||
// setting must not be what quietly clears it.
|
||||
func TestDevicePreferenceWritePreservesAdminShortName(t *testing.T) {
|
||||
stored, err := json.Marshal(normalizePreferences(map[string]any{"shortName": "Matt"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := preserveAdminPreferences(map[string]any{"showTitleLogo": false}, stored)
|
||||
if normalizePreferences(merged)["shortName"] != "Matt" {
|
||||
t.Errorf("shortName = %v, want preserved Matt", normalizePreferences(merged)["shortName"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicePreferenceWritePreservesAdminInitials(t *testing.T) {
|
||||
stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"}))
|
||||
if err != nil {
|
||||
|
||||
@@ -56,6 +56,13 @@ type radarrScheduleItem struct {
|
||||
MembyLifecycle string `json:"MembyLifecycle,omitempty"`
|
||||
MembyLifecycleText string `json:"MembyLifecycleText,omitempty"`
|
||||
MembyPlayable bool `json:"MembyPlayable"`
|
||||
// The Emby film this card stands for, when the library already holds it — the
|
||||
// MembySeriesItemId arrangement, and for the same reason: it is what decides whether
|
||||
// pressing the card opens the ordinary Memby page or the Radarr-only one. A film the
|
||||
// household has not downloaded carries none. The detail route resolves it again from
|
||||
// live data, because this row is cached for the day and a film imported at lunchtime
|
||||
// must not be stuck behind a cache until midnight.
|
||||
MembyMovieItemID string `json:"MembyMovieItemId,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) {
|
||||
@@ -90,7 +97,7 @@ func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := buildRadarrRow(movies, now, location)
|
||||
row, err := buildRadarrRow(movies, now, location, s.embyMovieIndex(ctx, movies))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,7 +121,37 @@ func (s *Server) cachedRadarrRow(ctx context.Context, key string) *recommend.Row
|
||||
return &row
|
||||
}
|
||||
|
||||
func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Location) (*recommend.Row, error) {
|
||||
// embyMovieIndex answers which of these films Emby already holds, keyed by TMDb id.
|
||||
//
|
||||
// Films are matched on the id both systems record rather than on their titles, which is
|
||||
// what the Sonarr schedule row has to fall back on: Radarr writes a TMDb id and the
|
||||
// library import asks Emby for ProviderIds, so there is nothing here to guess at. A
|
||||
// failure is not fatal — the row is about what is coming, and losing the link only costs a
|
||||
// downloaded card its ordinary detail page.
|
||||
func (s *Server) embyMovieIndex(ctx context.Context, movies []radarr.Movie) map[int]string {
|
||||
if s.store == nil || len(movies) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int, 0, len(movies))
|
||||
for _, movie := range movies {
|
||||
if movie.TMDBID > 0 {
|
||||
ids = append(ids, movie.TMDBID)
|
||||
}
|
||||
}
|
||||
found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", ids)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("emby movie index unavailable for schedule row", "error", err)
|
||||
return nil
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func buildRadarrRow(
|
||||
movies []radarr.Movie,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
embyItems map[int]string,
|
||||
) (*recommend.Row, error) {
|
||||
sort.SliceStable(movies, func(i, j int) bool {
|
||||
left, leftOK := effectiveRadarrRelease(movies[i])
|
||||
right, rightOK := effectiveRadarrRelease(movies[j])
|
||||
@@ -139,7 +176,9 @@ func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Locatio
|
||||
if localRelease.Before(dayStart) || !localRelease.Before(windowEnd) {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(toRadarrScheduleItem(movie, release, now, location))
|
||||
item := toRadarrScheduleItem(movie, release, now, location)
|
||||
item.MembyMovieItemID = embyItems[movie.TMDBID]
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// radarrItemPrefix is what a schedule card's id looks like: "radarr:412". The row has used
|
||||
// it since the movie schedule shipped, and it is also what the trailer routes recognise —
|
||||
// see radarrTrailerManifest — so a film with no Emby record can still be asked about
|
||||
// through the ordinary /v1/items/{id}/trailers path.
|
||||
const radarrItemPrefix = "radarr:"
|
||||
|
||||
// radarrMovieDetail is everything the Radarr-only detail page draws.
|
||||
//
|
||||
// It is deliberately not a BaseItem. A film Radarr is tracking but Emby has never imported
|
||||
// has no Emby record, no user data and nothing to play, and dressing it as one would put a
|
||||
// Play button, a watched tick and a progress bar on a page where all three are lies. The
|
||||
// television has a state of its own for this, and the moment Emby does hold the film
|
||||
// [EmbyItemID] is what sends the viewer to the ordinary page instead.
|
||||
//
|
||||
// Every piece of wording here is the gateway's, the arrangement the schedule cards, the
|
||||
// hero captions and the lifecycle tags already take: a phrasing invented next month reads
|
||||
// correctly on a television that predates it.
|
||||
type radarrMovieDetail struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"originalTitle,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
RuntimeMinutes int `json:"runtimeMinutes,omitempty"`
|
||||
Genres []string `json:"genres"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
Monitored bool `json:"monitored"`
|
||||
// Radarr's own lifecycle word, as the schedule card wears it: ANNOUNCED, IN CINEMAS,
|
||||
// RELEASED. Distinct from [StateLabel], which is about the household's copy.
|
||||
Lifecycle string `json:"lifecycle,omitempty"`
|
||||
LifecycleText string `json:"lifecycleText,omitempty"`
|
||||
// The subtle status treatment at the top of the page: "Coming Soon", "Awaiting
|
||||
// Release", "Not Yet Available", with one line under it saying what that means here.
|
||||
StateLabel string `json:"stateLabel"`
|
||||
StateDetail string `json:"stateDetail,omitempty"`
|
||||
// The one prominent date. "Expected 14 November 2026" when something has published the
|
||||
// day, "Expected November 2026" when the day is inferred rather than published, and
|
||||
// "Release date not yet announced" when nothing is known — never a precise-looking
|
||||
// date standing in for a guess.
|
||||
ExpectedLabel string `json:"expectedLabel"`
|
||||
// Cinema, digital and physical dates as Radarr holds them, for the viewer who wants to
|
||||
// know which of the three the headline came from. Any of them may be absent.
|
||||
ReleaseDates []radarrReleaseDate `json:"releaseDates"`
|
||||
// The sentence saying, in as many words, that this cannot be watched here yet.
|
||||
AvailabilityNotice string `json:"availabilityNotice"`
|
||||
// Whether the Trailer action should be offered at all. Deciding it here rather than on
|
||||
// the television is what keeps the button from being one that fails after selection.
|
||||
TrailerAvailable bool `json:"trailerAvailable"`
|
||||
// Scores from the same store every other page reads, when this title has been looked
|
||||
// up before. Empty is the honest answer and the strip simply does not appear.
|
||||
Ratings []movieRating `json:"ratings"`
|
||||
// Set once Emby holds the film. The television reopens on the ordinary detail page
|
||||
// when it sees this, which is how a title stops being a Radarr card without anything
|
||||
// having to be invalidated.
|
||||
EmbyItemID string `json:"embyItemId,omitempty"`
|
||||
}
|
||||
|
||||
type radarrReleaseDate struct {
|
||||
// cinema | digital | physical — a lookup key, not prose.
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRadarrMovie(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
_ = sess
|
||||
movieID, ok := radarrMovieID(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "a radarr movie id is required")
|
||||
return
|
||||
}
|
||||
if !s.radarrEnabled(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "radarr is not available")
|
||||
return
|
||||
}
|
||||
movie, err := s.radarrMovie(r.Context(), movieID)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(r.Context(), w, err, "could not read that movie")
|
||||
return
|
||||
}
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
detail := buildRadarrMovieDetail(movie, time.Now().In(location), location)
|
||||
detail.EmbyItemID = s.embyMovieItemID(r.Context(), movie.TMDBID)
|
||||
detail.Ratings = s.radarrMovieRatings(r.Context(), movie)
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// radarrMovieID reads the movie out of either form of id: the card's own "radarr:412", and
|
||||
// the bare number, because a caller holding the number should not have to know the prefix.
|
||||
func radarrMovieID(raw string) (int, bool) {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(raw), radarrItemPrefix)
|
||||
id, err := strconv.Atoi(trimmed)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// radarrMovie reads one film, preferring the household's cached catalogue.
|
||||
//
|
||||
// That catalogue is one request answering for every title, already shared across the house
|
||||
// and already refreshed on its own schedule, so a detail page opening normally costs Radarr
|
||||
// nothing at all. Asking directly is the fallback for a title added since it was read.
|
||||
func (s *Server) radarrMovie(ctx context.Context, movieID int) (radarr.Movie, error) {
|
||||
if movies, err := s.radarrMovieCatalogue(ctx); err == nil {
|
||||
for _, movie := range movies {
|
||||
if movie.ID == movieID {
|
||||
return movie, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.radarr.Movie(ctx, movieID)
|
||||
}
|
||||
|
||||
// embyMovieItemID is embyMovieIndex for one title. A failure costs the redirect and never
|
||||
// the page: the worst case is a Radarr page for a film Emby has quietly imported, which the
|
||||
// next home refresh corrects.
|
||||
func (s *Server) embyMovieItemID(ctx context.Context, tmdbID int) string {
|
||||
if s.store == nil || tmdbID <= 0 {
|
||||
return ""
|
||||
}
|
||||
found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", []int{tmdbID})
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("emby movie lookup failed for radarr detail", "error", err)
|
||||
return ""
|
||||
}
|
||||
return found[tmdbID]
|
||||
}
|
||||
|
||||
// radarrMovieRatings reuses the household's ratings store rather than Radarr's own scores.
|
||||
// Radarr carries a ratings block, but a page showing TMDb's number from Radarr here and
|
||||
// MDBList's everywhere else would print two different scores for one film under one name.
|
||||
func (s *Server) radarrMovieRatings(ctx context.Context, movie radarr.Movie) []movieRating {
|
||||
settings, enabled := s.mdblistSettings(ctx)
|
||||
if !enabled || s.mdblist == nil || s.store == nil {
|
||||
return []movieRating{}
|
||||
}
|
||||
key := store.RatingKey{MediaType: "movie"}
|
||||
switch {
|
||||
case movie.TMDBID > 0:
|
||||
key.Provider, key.ProviderID = "tmdb", strconv.Itoa(movie.TMDBID)
|
||||
case strings.TrimSpace(movie.IMDBID) != "":
|
||||
key.Provider, key.ProviderID = "imdb", strings.TrimSpace(movie.IMDBID)
|
||||
default:
|
||||
return []movieRating{}
|
||||
}
|
||||
ratings, err := s.loadMDBListRatings(ctx, settings.APIKey, key)
|
||||
if err != nil {
|
||||
s.logMDBListFailure(ctx, "ratings unavailable", radarrItemPrefix+strconv.Itoa(movie.ID), err)
|
||||
return []movieRating{}
|
||||
}
|
||||
return selectedMovieRatings(settings.Sources, ratings)
|
||||
}
|
||||
|
||||
// buildRadarrMovieDetail is the whole of the page's wording, and it is pure so that every
|
||||
// case a household can actually produce — a film with three dates, one with only a cinema
|
||||
// date, one Radarr has never been given a date for at all — is answerable without a Radarr.
|
||||
func buildRadarrMovieDetail(movie radarr.Movie, now time.Time, location *time.Location) radarrMovieDetail {
|
||||
release, hasRelease := effectiveRadarrRelease(movie)
|
||||
lifecycle := movieLifecycleTag(movie.Status)
|
||||
detail := radarrMovieDetail{
|
||||
ID: radarrItemPrefix + strconv.Itoa(movie.ID),
|
||||
Title: strings.TrimSpace(movie.Title),
|
||||
Overview: strings.TrimSpace(movie.Overview),
|
||||
Year: movie.Year,
|
||||
RuntimeMinutes: movie.Runtime,
|
||||
Genres: nonNilStrings(movie.Genres),
|
||||
Studio: strings.TrimSpace(movie.Studio),
|
||||
Certificate: strings.TrimSpace(movie.Certification),
|
||||
Monitored: movie.Monitored,
|
||||
Lifecycle: lifecycle.Status,
|
||||
LifecycleText: lifecycle.Label,
|
||||
ExpectedLabel: radarrExpectedLabel(release, hasRelease, now, location),
|
||||
ReleaseDates: radarrReleaseDates(movie, location),
|
||||
AvailabilityNotice: "Not available to watch in Memby yet",
|
||||
TrailerAvailable: strings.TrimSpace(movie.YouTubeTrailerID) != "",
|
||||
Ratings: []movieRating{},
|
||||
}
|
||||
// Only when it says something the heading does not, the rule the ordinary Details pane
|
||||
// already applies: a film whose original title is its title is the common case, and
|
||||
// printing it is a row that reads as a mistake.
|
||||
if original := strings.TrimSpace(movie.OriginalTitle); !strings.EqualFold(original, detail.Title) {
|
||||
detail.OriginalTitle = original
|
||||
}
|
||||
detail.StateLabel, detail.StateDetail = radarrMovieState(movie, release, hasRelease, now)
|
||||
return detail
|
||||
}
|
||||
|
||||
// radarrMovieState is the status treatment at the top of the page: two or three words for
|
||||
// what this film is doing, and a line saying what that means to somebody who wanted to
|
||||
// watch it tonight.
|
||||
func radarrMovieState(
|
||||
movie radarr.Movie, release radarrRelease, hasRelease bool, now time.Time,
|
||||
) (string, string) {
|
||||
switch {
|
||||
case movie.HasFile:
|
||||
// Downloaded, and yet this page is what opened — so Emby has not scanned it in
|
||||
// yet. A matter of minutes rather than of months, and worth saying so.
|
||||
return "Almost Ready", "Downloaded — waiting for Memby's library to pick it up"
|
||||
case !movie.Monitored:
|
||||
return "Not Tracked", "This film is not being monitored, so no copy is being sought"
|
||||
case !hasRelease:
|
||||
// Deliberately not "Release date not yet announced" — that is what the page has
|
||||
// just printed as its headline, and the line under a state exists to add to it.
|
||||
return "Awaiting Release", "Nothing to download until a date is announced"
|
||||
case release.at.After(now):
|
||||
return "Coming Soon", "Not released yet"
|
||||
default:
|
||||
return "Not Yet Available", "Released — waiting for a copy to arrive"
|
||||
}
|
||||
}
|
||||
|
||||
// radarrExpectedLabel is the one date the page leads with, and most of its job is refusing
|
||||
// to be precise about a date nothing has published.
|
||||
//
|
||||
// Radarr's digital date is a published fact and is printed to the day. The cinema-plus-a-
|
||||
// month estimate the schedule row falls back on is not, so it is printed to the month:
|
||||
// "Expected November 2026" is true where "Expected 14 November 2026" is a number somebody
|
||||
// would plan an evening around. Nothing known at all is said plainly rather than guessed.
|
||||
func radarrExpectedLabel(
|
||||
release radarrRelease, hasRelease bool, now time.Time, location *time.Location,
|
||||
) string {
|
||||
if !hasRelease {
|
||||
return "Release date not yet announced"
|
||||
}
|
||||
local := release.at.In(location)
|
||||
verb := "Expected "
|
||||
if !local.After(now.In(location)) {
|
||||
verb = "Released "
|
||||
}
|
||||
if release.estimated {
|
||||
return verb + local.Format("January 2006")
|
||||
}
|
||||
return verb + local.Format("2 January 2006")
|
||||
}
|
||||
|
||||
// radarrReleaseDates lists what Radarr actually holds, so a viewer can see which of the
|
||||
// three the headline came from. Only dates that exist appear; an absent one is absent
|
||||
// rather than dashed.
|
||||
func radarrReleaseDates(movie radarr.Movie, location *time.Location) []radarrReleaseDate {
|
||||
dates := []radarrReleaseDate{}
|
||||
add := func(kind, label string, value *time.Time) {
|
||||
if value == nil || value.IsZero() {
|
||||
return
|
||||
}
|
||||
dates = append(dates, radarrReleaseDate{
|
||||
Kind: kind,
|
||||
Label: label,
|
||||
Value: value.In(location).Format("2 January 2006"),
|
||||
})
|
||||
}
|
||||
add("cinema", "In cinemas", movie.InCinemas)
|
||||
add("digital", "Digital release", movie.DigitalRelease)
|
||||
add("physical", "Physical release", movie.PhysicalRelease)
|
||||
return dates
|
||||
}
|
||||
|
||||
// radarrTrailerManifest is the trailer chain for a film with no Emby record.
|
||||
//
|
||||
// It is the same manifest shape the ordinary path builds, so the television's existing
|
||||
// trailer machinery — the availability check, the resolve call, the report, the player's
|
||||
// candidate exclusion and its retry onto the next provider — works on a Radarr card with no
|
||||
// second implementation anywhere. The one candidate is Radarr's own YouTube trailer id,
|
||||
// which comes from TMDb's official trailer field and is ranked as an official source
|
||||
// rather than as a spare.
|
||||
func (s *Server) radarrTrailerManifest(ctx context.Context, itemID string, movieID int) (trailerManifest, error) {
|
||||
manifest := trailerManifest{SubjectID: itemID, Candidates: []trailerCandidate{}}
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return manifest, nil
|
||||
}
|
||||
movie, err := s.radarrMovie(ctx, movieID)
|
||||
if err != nil {
|
||||
return trailerManifest{}, err
|
||||
}
|
||||
manifest.Title = strings.TrimSpace(movie.Title)
|
||||
trailerID := strings.TrimSpace(movie.YouTubeTrailerID)
|
||||
if trailerID == "" {
|
||||
return manifest, nil
|
||||
}
|
||||
source := "https://www.youtube.com/watch?v=" + trailerID
|
||||
manifest.Candidates = append(manifest.Candidates, trailerCandidate{
|
||||
ID: trailerCandidateID("youtube", source),
|
||||
Provider: "youtube",
|
||||
Name: "Official Trailer",
|
||||
SourceURL: source,
|
||||
Priority: remoteTrailerPriority("youtube", "Official Trailer"),
|
||||
})
|
||||
return manifest, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
)
|
||||
|
||||
func radarrDetailDay(year int, month time.Month, day int) time.Time {
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func TestRadarrMovieID(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
raw string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{raw: "radarr:412", want: 412, ok: true},
|
||||
{raw: " radarr:412 ", want: 412, ok: true},
|
||||
{raw: "412", want: 412, ok: true},
|
||||
{raw: "", ok: false},
|
||||
{raw: "radarr:", ok: false},
|
||||
{raw: "radarr:0", ok: false},
|
||||
{raw: "radarr:-3", ok: false},
|
||||
{raw: "abc123", ok: false},
|
||||
} {
|
||||
id, ok := radarrMovieID(testCase.raw)
|
||||
if ok != testCase.ok || id != testCase.want {
|
||||
t.Fatalf("radarrMovieID(%q) = %d, %v; want %d, %v",
|
||||
testCase.raw, id, ok, testCase.want, testCase.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A published digital date is named to the day; the schedule row's cinema-plus-a-month
|
||||
// estimate is named only to the month, because it is a guess and a guess printed as
|
||||
// "14 November" is one somebody plans an evening around.
|
||||
func TestRadarrExpectedLabelPrecision(t *testing.T) {
|
||||
now := radarrDetailDay(2026, time.August, 19)
|
||||
published := radarrRelease{at: radarrDetailDay(2026, time.November, 14)}
|
||||
estimated := radarrRelease{at: radarrDetailDay(2026, time.November, 14), estimated: true}
|
||||
past := radarrRelease{at: radarrDetailDay(2026, time.March, 3)}
|
||||
|
||||
if got := radarrExpectedLabel(published, true, now, time.UTC); got != "Expected 14 November 2026" {
|
||||
t.Fatalf("published: %q", got)
|
||||
}
|
||||
if got := radarrExpectedLabel(estimated, true, now, time.UTC); got != "Expected November 2026" {
|
||||
t.Fatalf("estimated: %q", got)
|
||||
}
|
||||
if got := radarrExpectedLabel(past, true, now, time.UTC); got != "Released 3 March 2026" {
|
||||
t.Fatalf("past: %q", got)
|
||||
}
|
||||
if got := radarrExpectedLabel(radarrRelease{}, false, now, time.UTC); got != "Release date not yet announced" {
|
||||
t.Fatalf("unknown: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadarrMovieStateReadsTheHouseholdsCopy(t *testing.T) {
|
||||
now := radarrDetailDay(2026, time.August, 19)
|
||||
future := radarrRelease{at: radarrDetailDay(2026, time.November, 14)}
|
||||
past := radarrRelease{at: radarrDetailDay(2026, time.March, 3)}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
movie radarr.Movie
|
||||
release radarrRelease
|
||||
hasRelease bool
|
||||
want string
|
||||
}{
|
||||
{name: "coming soon", movie: radarr.Movie{Monitored: true}, release: future, hasRelease: true, want: "Coming Soon"},
|
||||
{name: "out but not here", movie: radarr.Movie{Monitored: true}, release: past, hasRelease: true, want: "Not Yet Available"},
|
||||
{name: "no date", movie: radarr.Movie{Monitored: true}, want: "Awaiting Release"},
|
||||
{name: "unmonitored", movie: radarr.Movie{}, release: future, hasRelease: true, want: "Not Tracked"},
|
||||
{
|
||||
name: "downloaded but unscanned",
|
||||
movie: radarr.Movie{Monitored: true, HasFile: true},
|
||||
release: past,
|
||||
hasRelease: true,
|
||||
want: "Almost Ready",
|
||||
},
|
||||
} {
|
||||
label, detail := radarrMovieState(testCase.movie, testCase.release, testCase.hasRelease, now)
|
||||
if label != testCase.want {
|
||||
t.Fatalf("%s: state = %q, want %q", testCase.name, label, testCase.want)
|
||||
}
|
||||
if detail == "" {
|
||||
t.Fatalf("%s: a state with no explanation under it", testCase.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRadarrMovieDetail(t *testing.T) {
|
||||
cinema := radarrDetailDay(2026, time.October, 2)
|
||||
digital := radarrDetailDay(2026, time.November, 14)
|
||||
movie := radarr.Movie{
|
||||
ID: 412,
|
||||
TMDBID: 9001,
|
||||
Title: "The Quiet Coast",
|
||||
OriginalTitle: "The Quiet Coast",
|
||||
Overview: " A harbour town in winter. ",
|
||||
Year: 2026,
|
||||
Runtime: 118,
|
||||
Genres: []string{"Drama", "Mystery"},
|
||||
Studio: "Kōwhai Pictures",
|
||||
Certification: "M",
|
||||
Status: "announced",
|
||||
Monitored: true,
|
||||
YouTubeTrailerID: "abc123",
|
||||
InCinemas: &cinema,
|
||||
DigitalRelease: &digital,
|
||||
Images: []radarr.Image{{CoverType: "poster"}, {CoverType: "fanart"}},
|
||||
}
|
||||
|
||||
detail := buildRadarrMovieDetail(movie, radarrDetailDay(2026, time.August, 19), time.UTC)
|
||||
|
||||
if detail.ID != "radarr:412" {
|
||||
t.Fatalf("id: %q", detail.ID)
|
||||
}
|
||||
if detail.Overview != "A harbour town in winter." {
|
||||
t.Fatalf("overview: %q", detail.Overview)
|
||||
}
|
||||
// The same title twice is the common case and reads as a mistake when printed.
|
||||
if detail.OriginalTitle != "" {
|
||||
t.Fatalf("original title repeated: %q", detail.OriginalTitle)
|
||||
}
|
||||
if detail.ExpectedLabel != "Expected 14 November 2026" {
|
||||
t.Fatalf("expected label: %q", detail.ExpectedLabel)
|
||||
}
|
||||
if detail.StateLabel != "Coming Soon" {
|
||||
t.Fatalf("state: %q", detail.StateLabel)
|
||||
}
|
||||
if detail.LifecycleText != "ANNOUNCED" || detail.Lifecycle != "announced" {
|
||||
t.Fatalf("lifecycle: %q/%q", detail.Lifecycle, detail.LifecycleText)
|
||||
}
|
||||
if !detail.TrailerAvailable {
|
||||
t.Fatalf("a film with a trailer id must offer the action: %+v", detail)
|
||||
}
|
||||
if detail.AvailabilityNotice == "" {
|
||||
t.Fatal("the page must say it cannot be watched here")
|
||||
}
|
||||
if len(detail.ReleaseDates) != 2 ||
|
||||
detail.ReleaseDates[0].Kind != "cinema" || detail.ReleaseDates[0].Value != "2 October 2026" ||
|
||||
detail.ReleaseDates[1].Kind != "digital" || detail.ReleaseDates[1].Value != "14 November 2026" {
|
||||
t.Fatalf("release dates: %+v", detail.ReleaseDates)
|
||||
}
|
||||
// Never null on the wire: the television decodes these as lists.
|
||||
if detail.Genres == nil || detail.Ratings == nil {
|
||||
t.Fatalf("nil collections: %+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// A film with nothing but a cinema date is the case the precision rule exists for: the
|
||||
// schedule row places it a month later, and the page must not present that as a fact.
|
||||
func TestBuildRadarrMovieDetailCinemaOnly(t *testing.T) {
|
||||
cinema := radarrDetailDay(2026, time.October, 2)
|
||||
detail := buildRadarrMovieDetail(
|
||||
radarr.Movie{ID: 7, Title: "Harbour Lights", InCinemas: &cinema, Monitored: true, Status: "inCinemas"},
|
||||
radarrDetailDay(2026, time.August, 19),
|
||||
time.UTC,
|
||||
)
|
||||
if detail.ExpectedLabel != "Expected November 2026" {
|
||||
t.Fatalf("expected label: %q", detail.ExpectedLabel)
|
||||
}
|
||||
if len(detail.ReleaseDates) != 1 || detail.ReleaseDates[0].Kind != "cinema" {
|
||||
t.Fatalf("release dates: %+v", detail.ReleaseDates)
|
||||
}
|
||||
if detail.TrailerAvailable {
|
||||
t.Fatal("a film with no trailer id must not offer the action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRadarrMovieDetailUnannounced(t *testing.T) {
|
||||
detail := buildRadarrMovieDetail(
|
||||
radarr.Movie{ID: 9, Title: "Untitled", OriginalTitle: "Sans Titre", Monitored: true, Status: "tba"},
|
||||
radarrDetailDay(2026, time.August, 19),
|
||||
time.UTC,
|
||||
)
|
||||
if detail.ExpectedLabel != "Release date not yet announced" {
|
||||
t.Fatalf("expected label: %q", detail.ExpectedLabel)
|
||||
}
|
||||
if detail.StateLabel != "Awaiting Release" {
|
||||
t.Fatalf("state: %q", detail.StateLabel)
|
||||
}
|
||||
if len(detail.ReleaseDates) != 0 {
|
||||
t.Fatalf("release dates: %+v", detail.ReleaseDates)
|
||||
}
|
||||
if detail.OriginalTitle != "Sans Titre" {
|
||||
t.Fatalf("a differing original title is worth printing: %q", detail.OriginalTitle)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInMonthWindo
|
||||
{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)
|
||||
}, now, location, map[int]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const (
|
||||
// Nothing here waits for the deployment to finish. The gateway that publishes this is the
|
||||
// one being retired; the one that comes back has no memory of having said it.
|
||||
func (s *Server) AnnounceDeployment(ctx context.Context) {
|
||||
s.publishAlert(ctx, deploymentAlert(time.Now().UTC()), deploymentAlertWindow)
|
||||
s.broadcast(ctx, notifySourceDeployment, deploymentAlert(time.Now().UTC()), deploymentAlertWindow)
|
||||
}
|
||||
|
||||
func deploymentAlert(now time.Time) clientAlert {
|
||||
@@ -84,7 +84,7 @@ func (s *Server) AnnounceLibrarySync(ctx context.Context, result library.Result)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibrarySync, clientAlert{
|
||||
// Keyed on the minute the sync finished: two runs are two pieces of news, but a
|
||||
// retried publish of the same run is not.
|
||||
ID: fmt.Sprintf("library:%d", now.Truncate(time.Minute).Unix()),
|
||||
@@ -153,13 +153,13 @@ func (s *Server) WatchEmbyReachability(ctx context.Context) {
|
||||
reachable = false
|
||||
s.log.Warn("emby unreachable, announcing",
|
||||
"component", "emby-health", "failures", failures, "error", err)
|
||||
s.publishAlert(ctx, s.reachabilityAlert(false), reachabilityAlertWindow)
|
||||
s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(false), reachabilityAlertWindow)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !reachable {
|
||||
s.log.Info("emby reachable again, announcing", "component", "emby-health")
|
||||
s.publishAlert(ctx, s.reachabilityAlert(true), reachabilityAlertWindow)
|
||||
s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(true), reachabilityAlertWindow)
|
||||
}
|
||||
reachable = true
|
||||
failures = 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -95,21 +96,26 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
||||
message := change.Current.Title + " was added to Sonarr."
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, user.ID, sourceKey, "show-added", "",
|
||||
"Show added", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("Sonarr addition notification failed",
|
||||
"user", user.ID, "show", change.Current.Title, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: "show-added",
|
||||
Source: notifySourceSonarrLifecycle,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Title: "Show added",
|
||||
Body: change.Current.Title + " was added to Sonarr.",
|
||||
SourceKey: sourceKey,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
s.declineUser(ctx, notification, sonarrDeclineReason(prefs, preferenceErrors[user.ID]))
|
||||
continue
|
||||
}
|
||||
notifications++
|
||||
if s.notifyUser(ctx, notification) {
|
||||
notifications++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, change := range cancellations {
|
||||
@@ -125,21 +131,26 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
||||
message := change.Current.Title + " is now listed as cancelled in Sonarr."
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, user.ID, sourceKey, "show-cancelled", "",
|
||||
"Show cancelled", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("Sonarr cancellation notification failed",
|
||||
"user", user.ID, "show", change.Current.Title, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: "show-cancelled",
|
||||
Source: notifySourceSonarrLifecycle,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Title: "Show cancelled",
|
||||
Body: change.Current.Title + " is now listed as cancelled in Sonarr.",
|
||||
SourceKey: sourceKey,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
s.declineUser(ctx, notification, sonarrDeclineReason(prefs, preferenceErrors[user.ID]))
|
||||
continue
|
||||
}
|
||||
notifications++
|
||||
if s.notifyUser(ctx, notification) {
|
||||
notifications++
|
||||
}
|
||||
}
|
||||
}
|
||||
s.log.Info("Sonarr lifecycle scan complete",
|
||||
@@ -176,3 +187,21 @@ func sonarrBecameCancelled(previous, current string) bool {
|
||||
current == "cancelled" || current == "canceled"
|
||||
return active && cancelled
|
||||
}
|
||||
|
||||
// sonarrDeclineReason is the sentence the console prints beside a skipped row.
|
||||
//
|
||||
// The three refusals are genuinely different answers to "why was I not told", and a page
|
||||
// that collapsed them into "skipped" would send an operator to change a setting that was
|
||||
// never the problem. A preference that would not load is its own case: it is read as "not
|
||||
// now" rather than as consent, and that is a fact about the gateway rather than about the
|
||||
// viewer.
|
||||
func sonarrDeclineReason(prefs store.NotificationPreferences, unreadable bool) string {
|
||||
switch {
|
||||
case unreadable:
|
||||
return "this viewer's notification preferences could not be read"
|
||||
case !prefs.Enabled:
|
||||
return "this viewer has notifications switched off"
|
||||
default:
|
||||
return "this viewer has Sonarr alerts switched off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,13 @@ func (s *Server) resolveLocalTrailer(
|
||||
}
|
||||
|
||||
func (s *Server) trailerManifest(ctx context.Context, sess store.Session, itemID string) (trailerManifest, error) {
|
||||
// A film Radarr is tracking has no Emby record to ask about local or remote trailers,
|
||||
// so its chain is built from what Radarr knows. It joins here rather than beside the
|
||||
// detail route because everything downstream — availability, resolve, report, the
|
||||
// player's walk through the candidates — is then unchanged for both kinds of subject.
|
||||
if movieID, ok := radarrMovieID(itemID); ok && strings.HasPrefix(itemID, radarrItemPrefix) {
|
||||
return s.radarrTrailerManifest(ctx, itemID, movieID)
|
||||
}
|
||||
key := cache.UserKey(sess.EmbyUserID, "trailers:v2:"+itemID)
|
||||
if s.cache != nil {
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -138,19 +139,31 @@ func (s *Server) sendWeeklyWatchTime(
|
||||
if total < watchTimeDigestFloor {
|
||||
continue
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
continue
|
||||
}
|
||||
monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username)
|
||||
message := weeklyDigestMessage(
|
||||
total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle)
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, account.ID, key, watchTimeWeeklyKind, "", "Your week in Memby", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("weekly watch-time summary failed", "user", account.ID, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: watchTimeWeeklyKind,
|
||||
Source: notifySourceWatchTime,
|
||||
UserID: account.ID,
|
||||
Username: account.Username,
|
||||
Title: "Your week in Memby",
|
||||
Body: message,
|
||||
SourceKey: key,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle},
|
||||
}
|
||||
// The floor above is a judgement about the news; this is a judgement about the
|
||||
// person, and only the second one is worth recording. "You have summaries switched
|
||||
// off" is the answer to somebody reporting that they never get one, and it is not
|
||||
// findable anywhere else.
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off")
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
if s.notifyUser(ctx, notification) {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent > 0 {
|
||||
s.log.Info("weekly watch-time summaries sent", "viewers", sent, "week", weekKey(now, location))
|
||||
@@ -183,18 +196,25 @@ func (s *Server) sendMonthlyWatchTime(
|
||||
if total < watchTimeDigestFloor {
|
||||
continue
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
continue
|
||||
}
|
||||
message := monthlyDigestMessage(total, monthName, watched.TopTitle)
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, account.ID, key, watchTimeMonthlyKind,
|
||||
"", monthName+" in Memby", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("monthly watch-time summary failed", "user", account.ID, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: watchTimeMonthlyKind,
|
||||
Source: notifySourceWatchTime,
|
||||
UserID: account.ID,
|
||||
Username: account.Username,
|
||||
Title: monthName + " in Memby",
|
||||
Body: message,
|
||||
SourceKey: key,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle, "month": monthID},
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off")
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
if s.notifyUser(ctx, notification) {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent > 0 {
|
||||
s.log.Info("monthly watch-time summaries sent", "viewers", sent, "month", monthID)
|
||||
|
||||
Reference in New Issue
Block a user