Big changes
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user