Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:55 +12:00
parent 70914400b4
commit a265636139
73 changed files with 9593 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
package api
import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
)
// Kinds are part of the wire contract: an unknown kind is rendered with the generic
// banner rather than dropped, so adding one never needs an app release.
const (
alertKindSonarrAired = "sonarr-aired"
// A TV shows one banner at a time; more than a few queued up is noise, not news.
maxAlerts = 3
)
// clientAlert is a short-lived, informational nudge delivered on the /v1/status poll —
// the only channel the app already listens to while it is open. It carries no action:
// the client slides it in, shows it for a few seconds and forgets it.
type clientAlert struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Message string `json:"message"`
ItemID string `json:"itemId,omitempty"`
ImageTag string `json:"imageTag,omitempty"`
AiredAt string `json:"airedAt,omitempty"`
}
// sonarrAiredAlerts reads the day's calendar through the same cache the airing-today row
// uses, so polling clients never cost a Sonarr request of their own.
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
if s.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 {
return nil
}
row, err := s.sonarrAiringTodayRow(ctx)
if err != nil {
s.log.Warn("sonarr alerts unavailable", "error", err)
return nil
}
if row == nil {
return nil
}
items := make([]sonarrScheduleItem, 0, len(row.Items))
for _, raw := range row.Items {
var item sonarrScheduleItem
if json.Unmarshal(raw, &item) != nil {
continue
}
items = append(items, item)
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
return buildSonarrAlerts(items, time.Now().In(location), s.cfg.SonarrAlertWindow)
}
// buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the
// gap the viewer would otherwise experience as "it's Tuesday, so why isn't it there".
// Anything already downloaded is deliberately silent: it is on the home screen, which
// says it better than a banner would.
func buildSonarrAlerts(items []sonarrScheduleItem, now time.Time, window time.Duration) []clientAlert {
if window <= 0 {
return nil
}
type dated struct {
alert clientAlert
airs time.Time
}
found := make([]dated, 0, len(items))
for _, item := range items {
if item.MembyAirsAt == "" {
continue
}
airsAt, err := time.Parse(time.RFC3339, item.MembyAirsAt)
if err != nil {
continue
}
// Not yet aired, or aired so long ago that saying so is no longer news.
if airsAt.After(now) || now.Sub(airsAt) > window {
continue
}
message, ok := airedMessage(item, airsAt)
if !ok {
continue
}
found = append(found, dated{
alert: clientAlert{
ID: item.ID + ":aired",
Kind: alertKindSonarrAired,
Title: item.Name,
Message: message,
ItemID: item.ID,
ImageTag: item.ImageTags["Primary"],
AiredAt: item.MembyAirsAt,
},
airs: airsAt,
})
}
// Newest first: if several aired inside the window, the most recent is the one the
// viewer is most likely to be waiting on.
sort.SliceStable(found, func(i, j int) bool { return found[i].airs.After(found[j].airs) })
if len(found) > maxAlerts {
found = found[:maxAlerts]
}
alerts := make([]clientAlert, 0, len(found))
for _, entry := range found {
alerts = append(alerts, entry.alert)
}
return alerts
}
func airedMessage(item sonarrScheduleItem, airsAt time.Time) (string, bool) {
episode := item.MembyEpisodeCode
if item.MembyEpisodeTitle != "" {
episode = fmt.Sprintf("%s — %s", episode, item.MembyEpisodeTitle)
}
switch item.MembyAvailability {
case "downloading":
return fmt.Sprintf("%s aired at %s and is downloading now.", episode, airsAt.Format("3:04 PM")), true
case "awaiting":
return fmt.Sprintf("%s aired at %s and will be in Emby soon.", episode, airsAt.Format("3:04 PM")), true
default:
// available (already watchable) and unmonitored (never coming) both have
// nothing useful to announce.
return "", false
}
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"strings"
"testing"
"time"
)
func airedItem(id, name, availability string, airsAt time.Time) sonarrScheduleItem {
return sonarrScheduleItem{
ID: id,
Name: name,
MembyEpisodeCode: "S02E04",
MembyEpisodeTitle: "The Crossing",
MembyAirsAt: airsAt.Format(time.RFC3339),
MembyAvailability: availability,
MembyAvailabilityText: availability,
ImageTags: map[string]string{"Primary": "sonarr"},
}
}
func TestBuildSonarrAlertsAnnouncesOnlyEpisodesNotYetInEmby(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 27, 21, 30, 0, 0, location)
aired := now.Add(-30 * time.Minute)
alerts := buildSonarrAlerts([]sonarrScheduleItem{
airedItem("sonarr:7:42", "Northbound", "awaiting", aired),
airedItem("sonarr:8:43", "Grabbed Show", "downloading", aired.Add(-time.Minute)),
airedItem("sonarr:9:44", "Already Here", "available", aired),
airedItem("sonarr:10:45", "Not Watched", "unmonitored", aired),
airedItem("sonarr:11:46", "Later Tonight", "upcoming", now.Add(90*time.Minute)),
airedItem("sonarr:12:47", "This Morning", "awaiting", now.Add(-8*time.Hour)),
}, now, 3*time.Hour)
if len(alerts) != 2 {
t.Fatalf("expected 2 alerts, got %d: %+v", len(alerts), alerts)
}
first := alerts[0]
if first.ID != "sonarr:7:42:aired" || first.Kind != alertKindSonarrAired {
t.Fatalf("unexpected alert identity: %+v", first)
}
if first.Title != "Northbound" || first.ItemID != "sonarr:7:42" || first.ImageTag != "sonarr" {
t.Fatalf("unexpected alert payload: %+v", first)
}
if !strings.Contains(first.Message, "S02E04 — The Crossing aired at 9:00 PM") ||
!strings.Contains(first.Message, "in Emby soon") {
t.Fatalf("unexpected awaiting message: %q", first.Message)
}
if !strings.Contains(alerts[1].Message, "downloading now") {
t.Fatalf("unexpected downloading message: %q", alerts[1].Message)
}
}
func TestBuildSonarrAlertsSortsNewestFirstAndCaps(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 27, 22, 0, 0, 0, location)
items := []sonarrScheduleItem{
airedItem("sonarr:1:1", "Oldest", "awaiting", now.Add(-100*time.Minute)),
airedItem("sonarr:2:2", "Newest", "awaiting", now.Add(-5*time.Minute)),
airedItem("sonarr:3:3", "Middle", "awaiting", now.Add(-40*time.Minute)),
airedItem("sonarr:4:4", "Older", "awaiting", now.Add(-80*time.Minute)),
}
alerts := buildSonarrAlerts(items, now, 3*time.Hour)
if len(alerts) != maxAlerts {
t.Fatalf("expected the list capped at %d, got %d", maxAlerts, len(alerts))
}
if alerts[0].Title != "Newest" || alerts[1].Title != "Middle" || alerts[2].Title != "Older" {
t.Fatalf("alerts are not newest-first: %+v", alerts)
}
}
func TestBuildSonarrAlertsDisabledByZeroWindow(t *testing.T) {
now := time.Date(2026, 7, 27, 22, 0, 0, 0, time.UTC)
items := []sonarrScheduleItem{airedItem("sonarr:1:1", "Northbound", "awaiting", now.Add(-time.Minute))}
if alerts := buildSonarrAlerts(items, now, 0); alerts != nil {
t.Fatalf("a zero window must disable alerts, got %+v", alerts)
}
}
+16
View File
@@ -0,0 +1,16 @@
package api
import (
"strings"
"testing"
)
func TestDetailFieldsIncludeEmbyPeople(t *testing.T) {
fields := strings.Split(fieldsDetail, ",")
for _, field := range fields {
if field == "People" {
return
}
}
t.Fatal("detail responses must request Emby's People field for cast metadata and portrait tags")
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"syscall"
"testing"
)
func TestExpectedClientDisconnect(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary", nil)
for _, err := range []error{
context.Canceled,
syscall.EPIPE,
syscall.ECONNRESET,
errors.New("write tcp: broken pipe"),
errors.New("client disconnected"),
} {
if !expectedClientDisconnect(req, err) {
t.Errorf("%q should be an expected client disconnect", err)
}
}
if expectedClientDisconnect(req, io.ErrUnexpectedEOF) {
t.Fatal("an upstream truncated image must remain a real warning")
}
}
func TestExpectedClientDisconnectUsesRequestContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary", nil).WithContext(ctx)
if !expectedClientDisconnect(req, errors.New("opaque response writer error")) {
t.Fatal("a cancelled request should be treated as viewer cancellation")
}
}
func TestTaggedImageConditionalRequestSkipsUpstream(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary?tag=abc123", nil)
req.Header.Set("If-None-Match", `"abc123"`)
rec := httptest.NewRecorder()
if !writeNotModifiedForTag(rec, req, "abc123") {
t.Fatal("matching image tag should short-circuit")
}
if rec.Code != http.StatusNotModified {
t.Fatalf("status = %d, want 304", rec.Code)
}
if got := rec.Header().Get("ETag"); got != `"abc123"` {
t.Fatalf("etag = %q", got)
}
}
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"log/slog"
"net/http"
"testing"
)
func TestRequestLogLevel(t *testing.T) {
tests := []struct {
path string
status int
want slog.Level
}{
{"/healthz", http.StatusOK, slog.LevelDebug},
{"/v1/status", http.StatusOK, slog.LevelDebug},
{"/v1/images/123/primary", http.StatusOK, slog.LevelDebug},
{"/v1/home", http.StatusOK, slog.LevelInfo},
{"/v1/images/123/primary", http.StatusNotFound, slog.LevelWarn},
{"/v1/home", http.StatusServiceUnavailable, slog.LevelError},
}
for _, test := range tests {
if got := requestLogLevel(test.path, test.status); got != test.want {
t.Errorf("requestLogLevel(%q, %d) = %v, want %v", test.path, test.status, got, test.want)
}
}
}
func TestClientLogValueIsNeverBlank(t *testing.T) {
if got := clientLogValue(""); got != "unknown" {
t.Fatalf("blank identity logged as %q", got)
}
if got := clientLogValue("0.1.60"); got != "0.1.60" {
t.Fatalf("reported identity changed to %q", got)
}
}
@@ -0,0 +1,25 @@
package api
import "testing"
func TestSubtitleMIME(t *testing.T) {
tests := map[string]string{
"srt": "application/x-subrip",
"subrip": "application/x-subrip",
"webvtt": "text/vtt",
"ass": "text/x-ssa",
"mov_text": "application/x-quicktime-tx3g",
"pgssub": "",
}
for codec, want := range tests {
if got := subtitleMIME(codec, ""); got != want {
t.Errorf("subtitleMIME(%q) = %q, want %q", codec, got, want)
}
}
}
func TestSubtitleMIMEFallsBackToDeliveryExtension(t *testing.T) {
if got := subtitleMIME("", "https://emby.example/subtitles/4/stream.vtt?api_key=x"); got != "text/vtt" {
t.Fatalf("subtitleMIME delivery fallback = %q", got)
}
}
+149
View File
@@ -0,0 +1,149 @@
package api
import (
"crypto/subtle"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/ponzischeme89/memby/server/internal/appupdate"
)
const maxReleaseSize = 250 << 20
var (
releaseVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
releaseFilenamePattern = regexp.MustCompile(`^memby-\d+\.\d+\.\d+\.apk$`)
)
// releasePublishAuth is deliberately separate from adminAuth: CI can publish an APK but
// cannot take the service offline, force an update, or read household analytics.
func (s *Server) releasePublishAuth(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg.ReleasePublishToken == "" {
http.NotFound(w, r)
return
}
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.ReleasePublishToken)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid release token")
return
}
h(w, r)
})
}
// handleReleasePublish accepts the signed APK produced by Gitea Actions, persists it,
// and atomically makes it the version offered to TVs.
func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxReleaseSize)
if err := r.ParseMultipartForm(16 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid release upload")
return
}
version := strings.TrimSpace(r.FormValue("version"))
if !releaseVersionPattern.MatchString(version) {
writeError(w, http.StatusBadRequest, "version must look like 0.1.54")
return
}
current := s.updatePolicy.get()
if current.LatestVersion != "" &&
appupdate.CompareVersions(version, current.LatestVersion) < 0 {
writeError(w, http.StatusConflict, "refusing to publish an older version")
return
}
source, _, err := r.FormFile("apk")
if err != nil {
writeError(w, http.StatusBadRequest, "signed APK is required")
return
}
defer source.Close()
if err := os.MkdirAll(s.cfg.ReleaseDir, 0o750); err != nil {
s.log.Error("release directory unavailable", "error", err)
writeError(w, http.StatusInternalServerError, "release storage unavailable")
return
}
temp, err := os.CreateTemp(s.cfg.ReleaseDir, ".memby-upload-*")
if err != nil {
s.log.Error("release temp file failed", "error", err)
writeError(w, http.StatusInternalServerError, "release storage unavailable")
return
}
tempName := temp.Name()
defer os.Remove(tempName)
written, copyErr := io.Copy(temp, source)
closeErr := temp.Close()
if copyErr != nil || closeErr != nil || written < 4 {
writeError(w, http.StatusBadRequest, "could not store APK")
return
}
// APKs are ZIP archives. This catches accidentally uploaded logs or HTML error pages
// before they become an update every TV is invited to install.
stored, err := os.Open(tempName)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not verify APK")
return
}
var magic [4]byte
_, readErr := io.ReadFull(stored, magic[:])
stored.Close()
if readErr != nil || string(magic[:2]) != "PK" {
writeError(w, http.StatusBadRequest, "uploaded file is not an APK")
return
}
filename := fmt.Sprintf("memby-%s.apk", version)
destination := filepath.Join(s.cfg.ReleaseDir, filename)
if err := os.Rename(tempName, destination); err != nil {
s.log.Error("release publish rename failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not publish APK")
return
}
if err := os.Chmod(destination, 0o640); err != nil {
s.log.Warn("release permissions could not be tightened", "error", err)
}
policy := appupdate.Policy{
Enabled: true,
LatestVersion: version,
MinimumVersion: current.MinimumVersion,
DownloadURL: s.cfg.PublicURL + "/updates/" + filename,
Notes: strings.TrimSpace(r.FormValue("notes")),
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
s.log.Error("release policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved")
return
}
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
s.log.Error("release policy reload failed", "error", err)
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be loaded")
return
}
s.log.Info("release published", "version", version, "bytes", written, "file", filename)
writeJSON(w, http.StatusCreated, s.updatePolicy.get())
}
// handleReleaseDownload serves immutable, signed APKs. They carry no household secrets,
// so downloads do not need a TV session and continue working through Android's installer.
func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
if !releaseFilenamePattern.MatchString(filename) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename))
}
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestReleasePublishAuth(t *testing.T) {
handler := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
t.Run("disabled is hidden", func(t *testing.T) {
s := &Server{cfg: config.Config{}}
rec := httptest.NewRecorder()
s.releasePublishAuth(handler).ServeHTTP(
rec,
httptest.NewRequest(http.MethodPost, "/admin/api/release", nil),
)
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
})
t.Run("valid bearer token is accepted", func(t *testing.T) {
s := &Server{cfg: config.Config{ReleasePublishToken: "release-secret"}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/release", nil)
req.Header.Set("Authorization", "Bearer release-secret")
s.releasePublishAuth(handler).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("got %d, want 204", rec.Code)
}
})
}
func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
dir := t.TempDir()
payload := []byte("PK signed apk")
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.54.apk"), payload, 0o600); err != nil {
t.Fatal(err)
}
s := &Server{cfg: config.Config{ReleaseDir: dir}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.54.apk", nil)
req.SetPathValue("filename", "memby-0.1.54.apk")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(payload) {
t.Fatalf("valid release response = %d %q", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/updates/../secrets", nil)
req.SetPathValue("filename", "../secrets")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("invalid filename got %d, want 404", rec.Code)
}
}
+345
View File
@@ -0,0 +1,345 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
const sonarrCalendarCachePrefix = "sonarr:calendar:"
const sonarrPrerollCachePrefix = "sonarr:preroll:"
type prerollScheduleResponse struct {
Today []prerollScheduleEntry `json:"today"`
ThisWeek []prerollScheduleEntry `json:"thisWeek"`
}
type prerollScheduleEntry struct {
Series string `json:"series"`
Episode string `json:"episode"`
EpisodeCode string `json:"episodeCode"`
Schedule string `json:"schedule"`
Availability string `json:"availability,omitempty"`
}
func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) {
if s.sonarr == nil {
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
return
}
schedule, err := s.sonarrPrerollSchedule(r.Context())
if err != nil {
// Pre-roll is decorative and must never become a playback dependency.
s.log.Warn("preroll schedule unavailable", "error", err)
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
return
}
writeJSON(w, http.StatusOK, schedule)
}
func (s *Server) sonarrPrerollSchedule(ctx context.Context) (prerollScheduleResponse, error) {
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
now := time.Now().In(location)
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
key := sonarrPrerollCachePrefix + dayStart.Format("2006-01-02")
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached prerollScheduleResponse
if json.Unmarshal(raw, &cached) == nil {
return normalizePrerollSchedule(cached), nil
}
}
s.sonarrMu.Lock()
defer s.sonarrMu.Unlock()
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached prerollScheduleResponse
if json.Unmarshal(raw, &cached) == nil {
return normalizePrerollSchedule(cached), nil
}
}
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 7))
if err != nil {
return emptyPrerollSchedule(), err
}
schedule := buildPrerollSchedule(episodes, now, location)
if raw, err := json.Marshal(schedule); err == nil {
if cacheErr := s.cache.Set(ctx, key, raw, s.cfg.SonarrTTL); cacheErr != nil {
s.log.Warn("preroll schedule cache write failed", "error", cacheErr)
}
}
return schedule, nil
}
func buildPrerollSchedule(
episodes []sonarr.Episode,
now time.Time,
location *time.Location,
) prerollScheduleResponse {
sort.SliceStable(episodes, func(i, j int) bool {
if episodes[i].AirDateUTC == nil {
return false
}
if episodes[j].AirDateUTC == nil {
return true
}
return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
})
todayEnd := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1)
result := emptyPrerollSchedule()
for _, episode := range episodes {
if episode.AirDateUTC == nil {
continue
}
airTime := episode.AirDateUTC.In(location)
entry := prerollScheduleEntry{
Series: episode.Series.Title,
Episode: episode.Title,
EpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
Availability: prerollAvailability(episode),
}
if airTime.Before(todayEnd) {
entry.Schedule = airTime.Format("3:04 PM")
if len(result.Today) < 4 {
result.Today = append(result.Today, entry)
}
} else {
entry.Schedule = airTime.Format("Monday · 3:04 PM")
if len(result.ThisWeek) < 6 {
result.ThisWeek = append(result.ThisWeek, entry)
}
}
}
return result
}
func prerollAvailability(episode sonarr.Episode) string {
switch {
case episode.HasFile:
return "Downloaded"
case episode.Grabbed:
return "Downloading"
default:
return ""
}
}
func emptyPrerollSchedule() prerollScheduleResponse {
return prerollScheduleResponse{
Today: []prerollScheduleEntry{},
ThisWeek: []prerollScheduleEntry{},
}
}
func normalizePrerollSchedule(value prerollScheduleResponse) prerollScheduleResponse {
if value.Today == nil {
value.Today = []prerollScheduleEntry{}
}
if value.ThisWeek == nil {
value.ThisWeek = []prerollScheduleEntry{}
}
return value
}
// sonarrScheduleItem deliberately looks enough like an Emby item to reuse the fast home
// card renderer, while its Memby fields mark it as informational and non-playable.
type sonarrScheduleItem struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
Overview string `json:"Overview,omitempty"`
ProductionYear int `json:"ProductionYear,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
Genres []string `json:"Genres"`
ImageTags map[string]string `json:"ImageTags"`
BackdropImageTags []string `json:"BackdropImageTags"`
MembySource string `json:"MembySource"`
MembyEpisodeTitle string `json:"MembyEpisodeTitle"`
MembyEpisodeCode string `json:"MembyEpisodeCode"`
MembyAirsAt string `json:"MembyAirsAt,omitempty"`
MembyAddedAt string `json:"MembyAddedAt,omitempty"`
MembyAirLabel string `json:"MembyAirLabel"`
MembyAvailability string `json:"MembyAvailability"`
MembyAvailabilityText string `json:"MembyAvailabilityText"`
MembyPlayable bool `json:"MembyPlayable"`
}
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
if s.sonarr == nil {
return nil, nil
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
now := time.Now().In(location)
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
cacheKey := sonarrCalendarCachePrefix + dayStart.Format("2006-01-02")
if row := s.cachedSonarrRow(ctx, cacheKey); row != nil {
return row, nil
}
// A shared lock prevents several users opening the app together from stampeding
// Sonarr on the one cache miss each day.
s.sonarrMu.Lock()
defer s.sonarrMu.Unlock()
if row := s.cachedSonarrRow(ctx, cacheKey); row != nil {
return row, nil
}
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 1))
if err != nil {
return nil, err
}
row, err := buildSonarrRow(episodes, now, location)
if err != nil {
return nil, err
}
body, err := json.Marshal(row)
if err == nil {
if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
s.log.Warn("sonarr calendar cache write failed", "error", cacheErr)
}
}
return row, nil
}
func (s *Server) cachedSonarrRow(ctx context.Context, key string) *recommend.Row {
raw, err := s.cache.Get(ctx, key)
if err != nil {
return nil
}
var row recommend.Row
if json.Unmarshal(raw, &row) != nil {
return nil
}
return &row
}
func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Location) (*recommend.Row, error) {
sort.SliceStable(episodes, func(i, j int) bool {
if episodes[i].AirDateUTC == nil {
return false
}
if episodes[j].AirDateUTC == nil {
return true
}
return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
})
items := make([]json.RawMessage, 0, len(episodes))
for _, episode := range episodes {
item := toSonarrScheduleItem(episode, now, location)
raw, err := json.Marshal(item)
if err != nil {
return nil, err
}
items = append(items, raw)
}
return &recommend.Row{
ID: "sonarr-airing-today",
Title: "Shows airing today",
Kind: "schedule",
Items: items,
}, nil
}
func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.Location) sonarrScheduleItem {
seriesID := episode.SeriesID
if episode.Series.ID > 0 {
seriesID = episode.Series.ID
}
item := sonarrScheduleItem{
ID: fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID),
Name: episode.Series.Title,
Type: "MembySonarrEpisode",
Overview: episode.Overview,
ProductionYear: episode.Series.Year,
RunTimeTicks: int64(episode.Runtime) * 600_000_000,
Genres: nonNilStrings(episode.Series.Genres),
ImageTags: map[string]string{},
BackdropImageTags: []string{},
MembySource: "sonarr",
MembyEpisodeTitle: episode.Title,
MembyEpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
MembyPlayable: false,
}
if hasCover(episode.Series.Images, "poster") {
item.ImageTags["Primary"] = "sonarr"
}
if hasCover(episode.Series.Images, "fanart") {
item.BackdropImageTags = []string{"sonarr"}
}
if episode.AirDateUTC != nil {
airTime := episode.AirDateUTC.In(location)
item.MembyAirsAt = airTime.Format(time.RFC3339)
if airTime.After(now) {
item.MembyAirLabel = "Airs today at " + airTime.Format("3:04 PM")
} else {
item.MembyAirLabel = "Aired today at " + airTime.Format("3:04 PM")
}
} else {
item.MembyAirLabel = "Airs today"
}
addedAt := episodeFileAddedAt(episode)
switch {
case episode.HasFile:
item.MembyAvailability = "available"
item.MembyAvailabilityText = "Downloaded"
if addedAt != nil {
localAdded := addedAt.In(location)
item.MembyAddedAt = localAdded.Format(time.RFC3339)
item.MembyAvailabilityText = "Added at " + localAdded.Format("3:04 PM")
}
case episode.Grabbed:
item.MembyAvailability = "downloading"
item.MembyAvailabilityText = "Downloading"
case !episode.Monitored:
item.MembyAvailability = "unmonitored"
item.MembyAvailabilityText = "Not monitored"
case episode.AirDateUTC != nil && episode.AirDateUTC.Before(now):
item.MembyAvailability = "awaiting"
item.MembyAvailabilityText = "Awaiting download"
default:
item.MembyAvailability = "upcoming"
item.MembyAvailabilityText = "Upcoming"
}
return item
}
func episodeFileAddedAt(episode sonarr.Episode) *time.Time {
if episode.EpisodeFile == nil {
return nil
}
return episode.EpisodeFile.DateAdded
}
func hasCover(images []sonarr.Image, coverType string) bool {
for _, image := range images {
if strings.EqualFold(image.CoverType, coverType) {
return true
}
}
return false
}
func nonNilStrings(values []string) []string {
if values == nil {
return []string{}
}
return values
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
air := time.Date(2026, 7, 27, 8, 0, 0, 0, time.UTC)
added := time.Date(2026, 7, 27, 8, 12, 0, 0, time.UTC)
row, err := buildSonarrRow([]sonarr.Episode{{
ID: 42,
SeriesID: 7,
SeasonNumber: 2,
EpisodeNumber: 4,
Title: "The Crossing",
AirDateUTC: &air,
HasFile: true,
Monitored: true,
EpisodeFile: &sonarr.EpisodeFile{DateAdded: &added},
Series: sonarr.Series{
ID: 7,
Title: "Northbound",
Images: []sonarr.Image{
{CoverType: "poster"},
{CoverType: "fanart"},
},
},
}}, time.Date(2026, 7, 27, 21, 0, 0, 0, location), location)
if err != nil {
t.Fatal(err)
}
if row.ID != "sonarr-airing-today" || row.Kind != "schedule" || len(row.Items) != 1 {
t.Fatalf("unexpected row: %+v", row)
}
var item sonarrScheduleItem
if err := json.Unmarshal(row.Items[0], &item); err != nil {
t.Fatal(err)
}
if item.ID != "sonarr:7:42" || item.MembyEpisodeCode != "S02E04" {
t.Fatalf("unexpected identity: %+v", item)
}
if item.MembyAvailability != "available" || item.MembyAvailabilityText != "Added at 8:12 PM" {
t.Fatalf("unexpected availability: %+v", item)
}
if item.ImageTags["Primary"] == "" || len(item.BackdropImageTags) != 1 {
t.Fatalf("artwork was not exposed: %+v", item)
}
}
func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 28, 10, 0, 0, 0, location)
today := time.Date(2026, 7, 28, 20, 30, 0, 0, location).UTC()
thursday := time.Date(2026, 7, 30, 19, 0, 0, 0, location).UTC()
schedule := buildPrerollSchedule([]sonarr.Episode{
{
SeasonNumber: 1, EpisodeNumber: 4, Title: "Tonight",
AirDateUTC: &today, Series: sonarr.Series{Title: "Northbound"},
},
{
SeasonNumber: 2, EpisodeNumber: 1, Title: "Later",
AirDateUTC: &thursday, HasFile: true, Series: sonarr.Series{Title: "Harbour"},
},
}, now, location)
if len(schedule.Today) != 1 || schedule.Today[0].Series != "Northbound" ||
schedule.Today[0].Schedule != "8:30 PM" {
t.Fatalf("unexpected today schedule: %+v", schedule.Today)
}
if len(schedule.ThisWeek) != 1 || schedule.ThisWeek[0].EpisodeCode != "S02E01" ||
schedule.ThisWeek[0].Availability != "Downloaded" {
t.Fatalf("unexpected week schedule: %+v", schedule.ThisWeek)
}
}