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)
}
}
+28
View File
@@ -0,0 +1,28 @@
package config
import "testing"
func TestDefaultClientAllowanceIsOne(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_MAX_CLIENTS_PER_USER", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.MaxClientsPerUser != 1 {
t.Fatalf("MaxClientsPerUser = %d, want 1", cfg.MaxClientsPerUser)
}
}
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_TRACEARR_URL", "http://tracearr")
t.Setenv("MEMBY_TRACEARR_API_KEY", "")
if _, err := Load(); err == nil {
t.Fatal("expected incomplete Tracearr configuration to fail")
}
}
@@ -0,0 +1,27 @@
package emby
import (
"net/url"
"testing"
"time"
)
func TestSubtitleURLUsesCanonicalVTTEndpoint(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "Memby", time.Second)
got := client.SubtitleURL(
Credentials{Token: "a b"},
"item id",
"source/id",
4,
)
parsed, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
if parsed.EscapedPath() != "/Videos/item%20id/source%2Fid/Subtitles/4/Stream.vtt" {
t.Fatalf("unexpected subtitle path %q", parsed.EscapedPath())
}
if parsed.Query().Get("api_key") != "a b" {
t.Fatalf("subtitle token was not preserved")
}
}
+749
View File
@@ -0,0 +1,749 @@
// Package foryou keeps Tracearr-derived recommendation data warm in PostgreSQL.
//
// Imports and ranking happen away from television requests. PostgreSQL is also the
// queue: dirty_since records work that still needs doing, so a restart cannot lose it.
package foryou
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
const (
importPageSize = 100
incrementalMaxPages = 10
incrementalMinPages = 2
unchangedPagesToStop = 2
preparedPoolReadLimit = 240
preparedRowSize = 20
)
type ImportResult struct {
Kind string `json:"kind"`
Pages int `json:"pages"`
Seen int `json:"seen"`
Changed int `json:"changed"`
Removed int64 `json:"removed"`
Duration time.Duration `json:"-"`
DurationMs int64 `json:"durationMs"`
}
type Service struct {
store *store.Store
tracearr *tracearr.Client
engine *recommend.Engine
emby *emby.Client
serviceCred emby.Credentials
log *slog.Logger
minRebuildAge time.Duration
refreshAge time.Duration
mu sync.Mutex
importRunning bool
building map[string]bool
}
// ConfigureHouseholdUsers enables background preparation for every enabled Emby user.
// The service token is kept server-side and is only used for read-only profile building.
func (s *Service) ConfigureHouseholdUsers(client *emby.Client, cred emby.Credentials) {
s.emby = client
s.serviceCred = cred
}
func New(
st *store.Store,
tracearrClient *tracearr.Client,
engine *recommend.Engine,
log *slog.Logger,
minRebuildAge, refreshAge time.Duration,
) *Service {
return &Service{
store: st, tracearr: tracearrClient, engine: engine, log: log,
minRebuildAge: minRebuildAge, refreshAge: refreshAge,
building: map[string]bool{},
}
}
func (s *Service) Running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.importRunning || len(s.building) > 0
}
func (s *Service) Stats(ctx context.Context) (store.ForYouStats, error) {
return s.store.ForYouStats(ctx)
}
func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, resultErr error) {
s.mu.Lock()
if s.importRunning {
s.mu.Unlock()
return result, errors.New("for you: a Tracearr import is already running")
}
s.importRunning = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.importRunning = false
s.mu.Unlock()
}()
started := time.Now().UTC()
result.Kind = "incremental"
count, err := s.store.TracearrSessionCount(ctx)
if err != nil {
return result, err
}
if count == 0 {
full = true
}
if full {
result.Kind = "full"
}
state, err := s.store.TracearrImportState(ctx)
if err != nil {
return result, err
}
defer func() {
now := time.Now().UTC()
if resultErr != nil {
state.LastError = resultErr.Error()
} else {
state.LastError = ""
state.LastIncrementalAt = &now
if full {
state.LastFullAt = &now
}
}
if err := s.store.SetTracearrImportState(context.WithoutCancel(ctx), state); err != nil {
s.log.Error("could not record Tracearr import state", "error", err)
}
result.Duration = time.Since(started)
result.DurationMs = result.Duration.Milliseconds()
}()
unchangedPages := 0
for pageNumber := 1; ; pageNumber++ {
page, err := s.tracearr.Page(ctx, pageNumber, importPageSize)
if err != nil {
return result, err
}
result.Pages++
result.Seen += len(page.Data)
imported := make([]store.TracearrSession, 0, len(page.Data))
keys := make([]store.TracearrSessionKey, 0, len(page.Data))
for _, session := range page.Data {
value, ok := importedSession(session, s.tracearr.ConfiguredServerID())
if !ok {
continue
}
imported = append(imported, value)
keys = append(keys, store.TracearrSessionKey{
ServerID: value.ServerID, SessionID: value.SessionID,
})
}
current, err := s.store.TracearrFingerprints(ctx, keys)
if err != nil {
return result, err
}
pageChanged := false
for _, session := range imported {
key := store.TracearrSessionKey{
ServerID: session.ServerID, SessionID: session.SessionID,
}
if !bytes.Equal(current[key], session.SourceFingerprint) {
pageChanged = true
result.Changed++
}
}
if err := s.store.UpsertTracearrSessions(ctx, imported, started); err != nil {
return result, err
}
if pageChanged {
unchangedPages = 0
} else {
unchangedPages++
}
reachedEnd := len(page.Data) == 0 ||
(page.Meta.Total > 0 && pageNumber*importPageSize >= page.Meta.Total)
if full && reachedEnd {
break
}
if !full && (reachedEnd ||
(pageNumber >= incrementalMinPages && unchangedPages >= unchangedPagesToStop) ||
pageNumber >= incrementalMaxPages) {
break
}
}
if full {
removed, err := s.store.DeleteTracearrSessionsNotSeenSince(
ctx, s.tracearr.ConfiguredServerID(), started,
)
if err != nil {
return result, err
}
result.Removed = removed
}
if result.Changed > 0 || result.Removed > 0 {
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
return result, err
}
users, err := s.store.ActiveRecommendationUsers(ctx)
if err != nil {
return result, err
}
for _, user := range users {
if err := s.store.MarkForYouDirty(ctx, user.EmbyUserID, user.Username); err != nil {
return result, err
}
}
}
s.log.Info("Tracearr import finished",
"kind", result.Kind, "pages", result.Pages, "seen", result.Seen,
"changed", result.Changed, "removed", result.Removed)
return result, nil
}
func (s *Service) Rebuild(ctx context.Context, sess store.Session, force bool) error {
if !s.beginBuild(sess.EmbyUserID) {
return nil
}
defer s.endBuild(sess.EmbyUserID)
_, poolBuiltAt, dirtySince, err := s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
if err != nil {
return err
}
if !force && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.minRebuildAge {
return nil
}
if !force && dirtySince == nil && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.refreshAge {
return nil
}
tracearrUserID, storedUsername, err := s.store.ForYouTracearrIdentity(ctx, sess.EmbyUserID)
if err != nil {
return err
}
username := strings.TrimSpace(sess.Username)
if username == "" {
username = storedUsername
}
imported, err := s.store.TracearrSessionsForUser(ctx, tracearrUserID, username)
if err != nil {
return err
}
sessions := make([]tracearr.Session, 0, len(imported))
for _, value := range imported {
sessions = append(sessions, tracearrSession(value))
}
result, err := s.engine.PrepareForYou(ctx, credentials(sess), username, sessions)
if err != nil {
_ = s.store.SetForYouError(context.WithoutCancel(ctx), sess.EmbyUserID, err)
return err
}
profile, candidates, err := storedResult(sess.EmbyUserID, time.Now().UTC(), result)
if err != nil {
return err
}
for _, mapping := range result.Mappings {
if err := s.store.UpdateTracearrSessionMapping(ctx, store.TracearrSessionKey{
ServerID: mapping.ServerID, SessionID: mapping.SessionID,
}, mapping.ItemID, mapping.SeriesID); err != nil {
return err
}
}
if err := s.store.ReplaceForYouPool(ctx, profile, candidates); err != nil {
_ = s.store.SetForYouError(context.WithoutCancel(ctx), sess.EmbyUserID, err)
return err
}
s.log.Info("For You pool rebuilt",
"user", sess.EmbyUserID, "sessions", len(sessions), "candidates", len(candidates))
return nil
}
func (s *Service) PreparedRows(
ctx context.Context,
sess store.Session,
minutes int,
) ([]recommend.Row, bool, bool, error) {
items, builtAt, err := s.store.PreparedForYou(
ctx, sess.EmbyUserID, minutes, preparedPoolReadLimit,
)
if err != nil {
return nil, false, false, err
}
if len(items) == 0 {
return nil, false, builtAt != nil && time.Since(*builtAt) >= s.refreshAge, nil
}
rows := buildPreparedRows(items, minutes, s.engine.MinRowItems)
stale := builtAt == nil || time.Since(*builtAt) >= s.refreshAge
return rows, len(rows) > 0, stale, nil
}
func (s *Service) MarkDirty(ctx context.Context, sess store.Session) {
if err := s.store.MarkForYouDirty(ctx, sess.EmbyUserID, sess.Username); err != nil {
s.log.Warn("could not mark For You dirty", "user", sess.EmbyUserID, "error", err)
}
}
func (s *Service) RefreshAsync(sess store.Session, force bool) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := s.Rebuild(ctx, sess, force); err != nil {
s.log.Warn("For You background rebuild failed", "user", sess.EmbyUserID, "error", err)
}
}()
}
func (s *Service) RebuildAll(ctx context.Context, force bool) error {
users, err := s.recommendationUsers(ctx)
if err != nil {
return err
}
for _, user := range users {
if err := s.Rebuild(ctx, user, force); err != nil {
s.log.Warn("For You user rebuild failed", "user", user.EmbyUserID, "error", err)
}
}
return nil
}
func (s *Service) MarkAllDirty(ctx context.Context) {
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
s.log.Warn("could not mark For You profiles dirty", "error", err)
}
users, err := s.store.ActiveRecommendationUsers(ctx)
if err != nil {
s.log.Warn("could not list For You users", "error", err)
return
}
for _, user := range users {
s.MarkDirty(ctx, user)
}
}
func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, error) {
active, err := s.store.ActiveRecommendationUsers(ctx)
if err != nil {
return nil, err
}
byID := make(map[string]store.Session, len(active))
for _, user := range active {
byID[user.EmbyUserID] = user
}
if s.emby == nil || strings.TrimSpace(s.serviceCred.Token) == "" {
return active, nil
}
embyUsers, err := s.emby.Users(ctx, s.serviceCred)
if err != nil {
s.log.Warn("Emby household users unavailable; using signed-in users", "error", err)
return active, nil
}
tracearrUsers, traceErr := s.allTracearrUsers(ctx)
if traceErr != nil {
s.log.Warn("Tracearr users unavailable; preparing Emby-only profiles", "error", traceErr)
}
traceByName := map[string]tracearr.User{}
for _, user := range tracearrUsers {
key := strings.ToLower(strings.TrimSpace(user.Username))
current, exists := traceByName[key]
if key != "" && (!exists || user.SessionCount > current.SessionCount) {
traceByName[key] = user
}
}
for _, user := range embyUsers {
if user.Policy.IsDisabled || strings.TrimSpace(user.ID) == "" {
continue
}
matched := traceByName[strings.ToLower(strings.TrimSpace(user.Name))]
if traceErr == nil {
if err := s.store.MatchRecommendationUser(
ctx, user.ID, user.Name, matched.ID, matched.Username,
); err != nil {
return nil, err
}
} else if err := s.store.MarkForYouDirty(ctx, user.ID, user.Name); err != nil {
return nil, err
}
byID[user.ID] = store.Session{
EmbyUserID: user.ID,
EmbyToken: s.serviceCred.Token,
Username: user.Name,
DeviceID: "memby-for-you-builder",
DeviceName: "Memby For You builder",
}
}
out := make([]store.Session, 0, len(byID))
for _, user := range byID {
out = append(out, user)
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
})
return out, nil
}
func (s *Service) allTracearrUsers(ctx context.Context) ([]tracearr.User, error) {
const pageSize = 100
out := []tracearr.User{}
for pageNumber := 1; ; pageNumber++ {
page, err := s.tracearr.Users(ctx, pageNumber, pageSize)
if err != nil {
return nil, err
}
out = append(out, page.Data...)
if len(page.Data) == 0 || page.Meta.Total <= pageNumber*pageSize {
return out, nil
}
}
}
func buildPreparedRows(
items []store.PreparedForYouItem,
minutes, minRowItems int,
) []recommend.Row {
if len(items) == 0 {
return nil
}
used := map[string]bool{}
rows := make([]recommend.Row, 0, 6)
appendRowWithMinimum := func(
id, title string,
candidates []store.PreparedForYouItem,
minimum int,
) bool {
selected := make([]store.PreparedForYouItem, 0, preparedRowSize)
for _, item := range candidates {
if used[item.ItemID] {
continue
}
selected = append(selected, item)
if len(selected) == preparedRowSize {
break
}
}
if len(selected) < minimum {
return false
}
rowItems := make([]json.RawMessage, 0, len(selected))
for _, item := range selected {
used[item.ItemID] = true
rowItems = append(rowItems, recommend.EnrichPreparedRecommendation(
item.Payload, item.RecommendationReason, item.CompatibilityLabel, minutes,
))
}
rows = append(rows, recommend.Row{
ID: id, Title: title, Kind: "for-you", Items: rowItems,
})
return true
}
appendRow := func(id, title string, candidates []store.PreparedForYouItem) bool {
return appendRowWithMinimum(id, title, candidates, minRowItems)
}
pickups := make([]store.PreparedForYouItem, 0, preparedRowSize)
for _, item := range items {
if item.ReasonKind == "pick-up" {
pickups = append(pickups, item)
}
}
// A pickup is valuable even when only one genuinely abandoned, unfinished series
// qualifies. Unlike generic recommendations, padding this shelf would make it lie.
appendRowWithMinimum("for-you:pick-up", "Pick these up again", pickups, 1)
topTitle := "Top picks for you"
if minutes > 0 {
topTitle = fmt.Sprintf("Top picks that fit in %d minutes", minutes)
}
appendRow("for-you:picks", topTitle, items)
appendGroupedRows := func(
prefix string,
key func(store.PreparedForYouItem) string,
title func(store.PreparedForYouItem) string,
maxRows int,
filter func(store.PreparedForYouItem) bool,
) {
groups := map[string][]store.PreparedForYouItem{}
order := []string{}
first := map[string]store.PreparedForYouItem{}
for _, item := range items {
if used[item.ItemID] || !filter(item) {
continue
}
groupKey := key(item)
if groupKey == "" {
continue
}
if _, exists := groups[groupKey]; !exists {
order = append(order, groupKey)
first[groupKey] = item
}
groups[groupKey] = append(groups[groupKey], item)
}
added := 0
for _, groupKey := range order {
if added == maxRows {
return
}
candidates := groups[groupKey]
if len(candidates) < minRowItems {
continue
}
if appendRow(prefix+rowKey(groupKey), title(first[groupKey]), candidates) {
added++
}
}
}
appendGroupedRows(
"for-you:because:",
func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID },
func(item store.PreparedForYouItem) string {
return "Because you finished " + item.ReasonSourceTitle
},
2,
func(item store.PreparedForYouItem) bool {
return strings.TrimSpace(item.ReasonSourceTitle) != ""
},
)
appendGroupedRows(
"for-you:genre:",
func(item store.PreparedForYouItem) string {
return strings.ToLower(strings.TrimSpace(item.ReasonGenre))
},
func(item store.PreparedForYouItem) string {
return "More " + item.ReasonGenre + " for you"
},
2,
func(item store.PreparedForYouItem) bool {
return strings.TrimSpace(item.ReasonGenre) != ""
},
)
compatible := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if !used[item.ItemID] && item.CompatibilityScore > 0.2 {
compatible = append(compatible, item)
}
}
appendRow("for-you:tv-ready", "Plays well on this TV", compatible)
return rows
}
func rowKey(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
var b strings.Builder
for _, r := range value {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() == 0 {
sum := sha256.Sum256([]byte(value))
return fmt.Sprintf("%x", sum[:6])
}
return b.String()
}
func (s *Service) Schedule(
ctx context.Context,
importEvery, fullEvery, refreshEvery time.Duration,
) {
if importEvery <= 0 {
s.log.Info("Tracearr auto-import disabled")
return
}
importTicker := time.NewTicker(importEvery)
defer importTicker.Stop()
var fullC, refreshC <-chan time.Time
var fullTicker, refreshTicker *time.Ticker
if fullEvery > 0 {
fullTicker = time.NewTicker(fullEvery)
fullC = fullTicker.C
defer fullTicker.Stop()
}
if refreshEvery > 0 {
refreshTicker = time.NewTicker(refreshEvery)
refreshC = refreshTicker.C
defer refreshTicker.Stop()
}
for {
select {
case <-ctx.Done():
return
case <-importTicker.C:
if _, err := s.Import(ctx, false); err != nil {
s.log.Warn("scheduled Tracearr import failed", "error", err)
} else {
_ = s.RebuildAll(ctx, false)
}
case <-fullC:
if _, err := s.Import(ctx, true); err != nil {
s.log.Warn("scheduled full Tracearr import failed", "error", err)
} else {
_ = s.RebuildAll(ctx, false)
}
case <-refreshC:
_ = s.RebuildAll(ctx, false)
}
}
}
func (s *Service) beginBuild(userID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.building[userID] {
return false
}
s.building[userID] = true
return true
}
func (s *Service) endBuild(userID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.building, userID)
}
func importedSession(session tracearr.Session, configuredServerID string) (store.TracearrSession, bool) {
if strings.TrimSpace(session.ID) == "" {
return store.TracearrSession{}, false
}
serverID := strings.TrimSpace(session.ServerID)
if serverID == "" {
serverID = strings.TrimSpace(configuredServerID)
}
if serverID == "" {
serverID = "default"
}
startedAt := parsedTime(session.StartedAt)
stoppedAt := parsedTime(session.StoppedAt)
fingerprintRaw, _ := json.Marshal(session)
fingerprint := sha256.Sum256(fingerprintRaw)
return store.TracearrSession{
ServerID: serverID, SessionID: session.ID, UserID: session.User.ID,
Username: session.User.Username, State: session.State,
MediaType: session.MediaType, MediaTitle: session.MediaTitle,
ShowTitle: session.ShowTitle, SeasonNumber: session.SeasonNumber,
EpisodeNumber: session.EpisodeNumber, ProductionYear: session.Year,
StartedAt: startedAt, StoppedAt: stoppedAt,
DurationMs: int64(session.DurationMs), ProgressMs: int64(session.ProgressMs),
TotalDurationMs: int64(session.TotalDurationMs), Watched: session.Watched,
Device: session.Device, Player: session.Player, Product: session.Product,
Platform: session.Platform, IsTranscode: session.IsTranscode,
VideoDecision: session.VideoDecision, AudioDecision: session.AudioDecision,
SourceVideoCodec: session.SourceVideoCodec, SourceAudioCodec: session.SourceAudioCodec,
SourceFingerprint: fingerprint[:],
}, true
}
func tracearrSession(session store.TracearrSession) tracearr.Session {
value := tracearr.Session{
ID: session.SessionID, ServerID: session.ServerID, State: session.State,
MediaType: session.MediaType, MediaTitle: session.MediaTitle,
ShowTitle: session.ShowTitle, SeasonNumber: session.SeasonNumber,
EpisodeNumber: session.EpisodeNumber, Year: session.ProductionYear,
DurationMs: tracearr.FlexibleInt64(session.DurationMs),
ProgressMs: tracearr.FlexibleInt64(session.ProgressMs),
TotalDurationMs: tracearr.FlexibleInt64(session.TotalDurationMs),
Watched: session.Watched, Device: session.Device, Player: session.Player,
Product: session.Product, Platform: session.Platform,
IsTranscode: session.IsTranscode, VideoDecision: session.VideoDecision,
AudioDecision: session.AudioDecision, SourceVideoCodec: session.SourceVideoCodec,
SourceAudioCodec: session.SourceAudioCodec,
}
if session.StartedAt != nil {
value.StartedAt = session.StartedAt.UTC().Format(time.RFC3339Nano)
}
if session.StoppedAt != nil {
value.StoppedAt = session.StoppedAt.UTC().Format(time.RFC3339Nano)
}
value.User.ID = session.UserID
value.User.Username = session.Username
return value
}
func storedResult(
userID string,
builtAt time.Time,
result recommend.PreparedResult,
) (store.RecommendationProfile, []store.ForYouCandidate, error) {
genre, err := json.Marshal(result.Profile.GenreAffinity)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
title, err := json.Marshal(result.Profile.TitleAffinity)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
studio, err := json.Marshal(result.Profile.StudioAffinity)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
codecs, err := json.Marshal(result.Profile.CodecOutcomes)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
profile := store.RecommendationProfile{
EmbyUserID: userID, TracearrUserID: result.Profile.TracearrUserID,
TracearrUsername: result.Profile.TracearrUsername,
SourceSessionCount: result.Profile.SourceSessionCount,
MeanCompletionRatio: result.Profile.MeanCompletionRatio,
TypicalSessionMinutes: result.Profile.TypicalSessionMinutes,
GenreAffinity: genre, TitleAffinity: title, StudioAffinity: studio,
CodecOutcomes: codecs, SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
}
candidates := make([]store.ForYouCandidate, 0, len(result.Candidates))
for _, value := range result.Candidates {
candidates = append(candidates, store.ForYouCandidate{
ItemID: value.ItemID, BaseRank: value.BaseRank, BaseScore: value.BaseScore,
RuntimeMinutes: value.RuntimeMinutes, AffinityScore: value.AffinityScore,
CompatibilityScore: value.CompatibilityScore,
CompatibilityLabel: value.CompatibilityLabel, ReasonKind: value.ReasonKind,
ReasonGenre: value.ReasonGenre,
ReasonSourceSessionID: value.ReasonSourceSessionID,
ReasonSourceItemID: value.ReasonSourceItemID,
ReasonSourceTitle: value.ReasonSourceTitle,
RecommendationReason: value.RecommendationReason,
})
}
return profile, candidates, nil
}
func parsedTime(value string) *time.Time {
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
if err != nil {
return nil
}
parsed = parsed.UTC()
return &parsed
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
}
}
+112
View File
@@ -0,0 +1,112 @@
package foryou
import (
"bytes"
"encoding/json"
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T) {
session := tracearr.Session{
ID: "session-1", MediaType: "movie", MediaTitle: "Arrival",
ProgressMs: 500, TotalDurationMs: 1000,
}
session.User.ID = "user-1"
session.User.Username = "Matt"
first, ok := importedSession(session, "configured-server")
if !ok {
t.Fatal("session was rejected")
}
if first.ServerID != "configured-server" || first.SessionID != "session-1" {
t.Fatalf("key = %q/%q", first.ServerID, first.SessionID)
}
second, _ := importedSession(session, "configured-server")
if !bytes.Equal(first.SourceFingerprint, second.SourceFingerprint) {
t.Fatal("unchanged session did not produce a stable fingerprint")
}
session.ProgressMs = 750
updated, _ := importedSession(session, "configured-server")
if bytes.Equal(first.SourceFingerprint, updated.SourceFingerprint) {
t.Fatal("updated progress was not detected by the fingerprint")
}
}
func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) {
items := make([]store.PreparedForYouItem, 0, 120)
for i := 0; i < 120; i++ {
sourceID, sourceTitle := "six-feet-under", "Six Feet Under"
genre := "Drama"
if i%2 == 1 {
sourceID, sourceTitle = "arrival", "Arrival"
genre = "Science Fiction"
}
id := "item-" + itoaForTest(i)
items = append(items, store.PreparedForYouItem{
ItemID: id, BaseRank: i + 1,
Payload: json.RawMessage(`{"Id":"` + id + `"}`),
CompatibilityScore: 0.8,
CompatibilityLabel: "Direct plays well on this TV",
RecommendationReason: "Because you finished " + sourceTitle,
ReasonGenre: genre, ReasonSourceItemID: sourceID,
ReasonSourceTitle: sourceTitle,
})
}
rows := buildPreparedRows(items, 0, 4)
if len(rows) < 4 {
t.Fatalf("expected several prepared rows, got %d", len(rows))
}
titles := map[string]bool{}
itemIDs := map[string]bool{}
for _, row := range rows {
titles[row.Title] = true
for _, raw := range row.Items {
var item struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatal(err)
}
if itemIDs[item.ID] {
t.Fatalf("item %q appeared in more than one row", item.ID)
}
itemIDs[item.ID] = true
}
}
if !titles["Because you finished Six Feet Under"] ||
!titles["Because you finished Arrival"] {
t.Fatalf("completed-title rows = %+v", titles)
}
}
func TestBuildPreparedRowsKeepsASingleGenuinePickup(t *testing.T) {
rows := buildPreparedRows([]store.PreparedForYouItem{{
ItemID: "show-1", BaseRank: 1,
Payload: json.RawMessage(`{"Id":"show-1"}`),
ReasonKind: "pick-up",
RecommendationReason: "You left this in season 1 · pick it up again",
}}, 0, 4)
if len(rows) != 1 || rows[0].ID != "for-you:pick-up" ||
rows[0].Title != "Pick these up again" || len(rows[0].Items) != 1 {
t.Fatalf("pickup rows = %+v", rows)
}
}
func itoaForTest(value int) string {
if value == 0 {
return "0"
}
var digits [20]byte
position := len(digits)
for value > 0 {
position--
digits[position] = byte('0' + value%10)
value /= 10
}
return string(digits[position:])
}
+204
View File
@@ -0,0 +1,204 @@
// Package logging configures the server's human-readable, structured log output.
package logging
import (
"context"
"io"
"log/slog"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// ParseLevel returns a supported slog level, defaulting to INFO for empty or invalid
// values. Keeping this forgiving prevents a typo in Docker configuration from stopping
// the gateway.
func ParseLevel(value string) slog.Level {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "DEBUG":
return slog.LevelDebug
case "WARN", "WARNING":
return slog.LevelWarn
case "ERROR":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// New returns a text logger suited to `docker compose logs`. Fields remain structured
// key=value pairs, but each event is one compact line rather than a JSON object.
func New(w io.Writer, level slog.Leveler) *slog.Logger {
logger, _ := NewBuffered(w, level, 0)
return logger
}
// Event is the browser-safe representation of one structured server log record.
type Event struct {
Sequence int64 `json:"sequence"`
OccurredAt time.Time `json:"occurredAt"`
Level string `json:"level"`
Message string `json:"message"`
Attributes map[string]string `json:"attributes,omitempty"`
}
// EventPage is cursor based so an admin browser can drain bursts without repeatedly
// downloading records it has already rendered.
type EventPage struct {
Events []Event `json:"events"`
Next int64 `json:"next"`
Oldest int64 `json:"oldest"`
Latest int64 `json:"latest"`
Dropped int64 `json:"dropped"`
HasMore bool `json:"hasMore"`
}
// Buffer is a bounded, concurrency-safe ring of recent structured log records.
// Bounding is important: a broken TV can generate traffic indefinitely, while 20,000
// records is still enough context for an operator to inspect a sustained incident.
type Buffer struct {
mu sync.RWMutex
capacity int
events []Event
next atomic.Int64
}
// NewBuffered writes normal text logs and mirrors accepted records into a ring buffer.
func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *Buffer) {
options := &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey {
return slog.String(slog.TimeKey, attr.Value.Time().UTC().Format(time.RFC3339))
}
return attr
},
}
buffer := &Buffer{capacity: capacity}
text := slog.NewTextHandler(w, options)
if capacity <= 0 {
return slog.New(text), buffer
}
return slog.New(&captureHandler{next: text, buffer: buffer, level: level}), buffer
}
type captureHandler struct {
next slog.Handler
buffer *Buffer
level slog.Leveler
attrs []slog.Attr
groups []string
}
func (h *captureHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.level.Level() && h.next.Enabled(ctx, level)
}
func (h *captureHandler) Handle(ctx context.Context, record slog.Record) error {
attributes := make(map[string]string, record.NumAttrs()+len(h.attrs))
for _, attr := range h.attrs {
addAttribute(attributes, h.groups, attr)
}
record.Attrs(func(attr slog.Attr) bool {
addAttribute(attributes, h.groups, attr)
return true
})
h.buffer.append(Event{
OccurredAt: record.Time.UTC(),
Level: record.Level.String(),
Message: record.Message,
Attributes: attributes,
})
return h.next.Handle(ctx, record)
}
func (h *captureHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
clone := *h
clone.next = h.next.WithAttrs(attrs)
clone.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...)
return &clone
}
func (h *captureHandler) WithGroup(name string) slog.Handler {
clone := *h
clone.next = h.next.WithGroup(name)
clone.groups = append(append([]string{}, h.groups...), name)
return &clone
}
func addAttribute(target map[string]string, groups []string, attr slog.Attr) {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return
}
key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
if attr.Value.Kind() == slog.KindGroup {
for _, child := range attr.Value.Group() {
addAttribute(target, append(groups, attr.Key), child)
}
return
}
switch attr.Value.Kind() {
case slog.KindDuration:
target[key] = attr.Value.Duration().String()
case slog.KindTime:
target[key] = attr.Value.Time().UTC().Format(time.RFC3339Nano)
default:
target[key] = attr.Value.String()
if attr.Value.Kind() == slog.KindInt64 {
target[key] = strconv.FormatInt(attr.Value.Int64(), 10)
}
}
}
func (b *Buffer) append(event Event) {
event.Sequence = b.next.Add(1)
b.mu.Lock()
defer b.mu.Unlock()
if len(b.events) == b.capacity {
copy(b.events, b.events[1:])
b.events[len(b.events)-1] = event
return
}
b.events = append(b.events, event)
}
// Events returns records strictly newer than after, up to limit. If the caller fell
// behind the ring, Dropped reports the gap and delivery resumes at the oldest record.
func (b *Buffer) Events(after int64, limit int) EventPage {
if limit < 1 {
limit = 250
}
if limit > 1000 {
limit = 1000
}
b.mu.RLock()
defer b.mu.RUnlock()
latest := b.next.Load()
page := EventPage{Next: after, Latest: latest, Events: []Event{}}
if len(b.events) == 0 {
return page
}
page.Oldest = b.events[0].Sequence
if after < page.Oldest-1 {
page.Dropped = page.Oldest - after - 1
after = page.Oldest - 1
}
start := len(b.events)
for i := range b.events {
if b.events[i].Sequence > after {
start = i
break
}
}
end := min(start+limit, len(b.events))
page.Events = append(page.Events, b.events[start:end]...)
if len(page.Events) > 0 {
page.Next = page.Events[len(page.Events)-1].Sequence
}
page.HasMore = page.Next < latest
return page
}
+58
View File
@@ -0,0 +1,58 @@
package logging
import (
"bytes"
"log/slog"
"strings"
"testing"
)
func TestNewWritesReadableStructuredText(t *testing.T) {
var output bytes.Buffer
New(&output, slog.LevelInfo).Info("server ready", "listen", ":8080")
line := output.String()
if strings.HasPrefix(strings.TrimSpace(line), "{") {
t.Fatalf("expected text output, got JSON: %s", line)
}
for _, want := range []string{`level=INFO`, `msg="server ready"`, `listen=:8080`} {
if !strings.Contains(line, want) {
t.Errorf("output %q does not contain %q", line, want)
}
}
}
func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T) {
var output bytes.Buffer
logger, buffer := NewBuffered(&output, slog.LevelDebug, 3)
for i := 1; i <= 5; i++ {
logger.Info("request complete", "number", i)
}
first := buffer.Events(0, 2)
if first.Dropped != 2 || len(first.Events) != 2 || !first.HasMore {
t.Fatalf("unexpected first page: %+v", first)
}
if first.Events[0].Sequence != 3 || first.Events[0].Attributes["number"] != "3" {
t.Fatalf("oldest retained event was not delivered: %+v", first.Events[0])
}
second := buffer.Events(first.Next, 2)
if len(second.Events) != 1 || second.Events[0].Sequence != 5 || second.HasMore {
t.Fatalf("unexpected second page: %+v", second)
}
}
func TestParseLevel(t *testing.T) {
tests := map[string]slog.Level{
"": slog.LevelInfo,
"debug": slog.LevelDebug,
"WARNING": slog.LevelWarn,
"error": slog.LevelError,
"unknown": slog.LevelInfo,
}
for input, want := range tests {
if got := ParseLevel(input); got != want {
t.Errorf("ParseLevel(%q) = %v, want %v", input, got, want)
}
}
}
+695
View File
@@ -0,0 +1,695 @@
package recommend
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
type PreparedLibrarySource interface {
AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error)
}
type PreparedEvidence struct {
SessionID string `json:"sessionId"`
ItemID string `json:"itemId"`
Title string `json:"title"`
Genres []string `json:"genres,omitempty"`
}
type PreparedTitleAffinity struct {
Weight float64 `json:"weight"`
Title string `json:"title"`
SessionID string `json:"sessionId,omitempty"`
}
type PreparedSessionMapping struct {
ServerID string
SessionID string
ItemID string
SeriesID string
}
type PreparedProfile struct {
TracearrUserID string
TracearrUsername string
SourceSessionCount int
MeanCompletionRatio float64
TypicalSessionMinutes int
GenreAffinity map[string]float64
TitleAffinity map[string]PreparedTitleAffinity
StudioAffinity map[string]float64
CodecOutcomes map[string]map[string]int
SignalsThrough *time.Time
}
type PreparedCandidate struct {
ItemID string
BaseRank int
BaseScore float64
RuntimeMinutes int
AffinityScore float64
CompatibilityScore float64
CompatibilityLabel string
ReasonKind string
ReasonGenre string
ReasonSourceSessionID string
ReasonSourceItemID string
ReasonSourceTitle string
RecommendationReason string
}
type PreparedResult struct {
Profile PreparedProfile
Candidates []PreparedCandidate
Mappings []PreparedSessionMapping
}
var ErrPreparedLibraryUnavailable = errors.New("recommend: prepared library unavailable")
// PrepareForYou performs the expensive work outside a television request. It consumes
// locally imported Tracearr sessions and the complete imported catalogue, producing a
// compact profile and an intentionally over-provisioned ranked pool.
func (e *Engine) PrepareForYou(
ctx context.Context,
cred emby.Credentials,
username string,
sessions []tracearr.Session,
) (PreparedResult, error) {
sessions = recommendationSessions(sessions)
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return PreparedResult{}, err
}
profile := BuildProfile(history, favorites)
library, ok := e.Library.(PreparedLibrarySource)
if !ok {
return PreparedResult{}, ErrPreparedLibraryUnavailable
}
raws, err := library.AllRecommendationCandidates(ctx)
if err != nil {
return PreparedResult{}, err
}
catalogue := Decode(raws)
if len(catalogue) == 0 {
return PreparedResult{}, ErrPreparedLibraryUnavailable
}
browsed := map[string]bool{}
if e.Behavior != nil {
browsedRaws, browseErr := e.Behavior.BrowsingCandidates(
ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 30,
)
if browseErr != nil {
e.log.Warn("browsing signals unavailable during For You rebuild", "error", browseErr)
} else {
for i, item := range Decode(browsedRaws) {
profile.absorbTaste(item, 0.55*powDecay(0.92, i))
browsed[item.ID] = true
}
}
}
index := newCatalogueIndex(catalogue)
evidenceByGenre := map[string][]PreparedEvidence{}
evidenceSeen := map[string]bool{}
addCompletedEvidence := func(item Item, sessionID string) {
itemID := item.ID
title := item.Name
if strings.EqualFold(item.Type, "Episode") &&
strings.TrimSpace(item.SeriesName) != "" {
title = item.SeriesName
if item.SeriesID != "" {
itemID = item.SeriesID
}
}
for _, genre := range item.Genres {
genreKey := strings.ToLower(strings.TrimSpace(genre))
evidenceKey := genreKey + "|" + itemID
if genreKey == "" || itemID == "" || evidenceSeen[evidenceKey] {
continue
}
evidenceSeen[evidenceKey] = true
evidenceByGenre[genreKey] = append(
evidenceByGenre[genreKey],
PreparedEvidence{
SessionID: sessionID,
ItemID: itemID,
Title: title,
Genres: append([]string(nil), item.Genres...),
},
)
}
}
// Emby's played history is already a trustworthy completion signal and gives the
// explanation pool breadth even when Tracearr title matching is sparse.
for _, item := range history {
if item.UserData.Played {
addCompletedEvidence(item, "")
}
}
titleAffinity := map[string]PreparedTitleAffinity{}
mappings := make([]PreparedSessionMapping, 0, len(sessions))
var completionTotal float64
durations := make([]int, 0, len(sessions))
var signalsThrough *time.Time
tracearrUserID := ""
tracearrUsername := strings.TrimSpace(username)
titleSignalCount := map[string]int{}
for i, session := range sessions {
completion := session.Completion()
completionTotal += completion
if minutes := int(int64(session.DurationMs) / 60_000); minutes > 0 {
durations = append(durations, minutes)
}
if started, ok := parseTracearrTime(session.StartedAt); ok &&
(signalsThrough == nil || started.After(*signalsThrough)) {
value := started
signalsThrough = &value
}
if tracearrUserID == "" {
tracearrUserID = strings.TrimSpace(session.User.ID)
}
if tracearrUsername == "" {
tracearrUsername = strings.TrimSpace(session.User.Username)
}
if completion > 0 {
profile.SeenTitles[tracearrSeenKey(session)] = true
}
item, matched := index.match(session)
if !matched {
continue
}
seriesID := item.SeriesID
if strings.EqualFold(session.MediaType, "episode") &&
strings.EqualFold(item.Type, "Series") {
seriesID = item.ID
}
mappings = append(mappings, PreparedSessionMapping{
ServerID: session.ServerID, SessionID: session.ID,
ItemID: item.ID, SeriesID: seriesID,
})
// Episode-heavy programmes should be strong signals, but not dozens of
// independent votes. Each repeat contributes less than the previous one.
repeats := titleSignalCount[item.ID]
titleSignalCount[item.ID] = repeats + 1
weight := (0.2 + completion) * math.Pow(0.985, float64(i)) *
math.Pow(0.65, float64(repeats))
profile.absorbTaste(item, weight)
if completion > 0 {
if item.ID != "" {
profile.Seen[item.ID] = true
}
if item.SeriesID != "" {
profile.Seen[item.SeriesID] = true
}
}
current := titleAffinity[item.ID]
current.Weight += weight
current.Title = item.Name
if current.SessionID == "" {
current.SessionID = session.ID
}
titleAffinity[item.ID] = current
if completion >= 0.9 {
addCompletedEvidence(item, session.ID)
}
}
compatibility := buildCompatibilityProfile(sessions)
type scored struct {
item Item
base float64
compatibility float64
score float64
}
ranked := make([]scored, 0, len(catalogue))
seenCandidates := map[string]bool{}
for _, candidate := range catalogue {
if candidate.ID == "" || seenCandidates[candidate.ID] {
continue
}
seenCandidates[candidate.ID] = true
base := profile.Score(candidate)
if base < 0 {
continue
}
compatibilityValue := compatibilityScore(candidate, compatibility)
ranked = append(ranked, scored{
item: candidate, base: base, compatibility: compatibilityValue,
score: base + compatibilityValue*1.4,
})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
return ranked[i].item.Name < ranked[j].item.Name
})
prepared := make([]PreparedCandidate, 0, len(ranked))
completedReasonCounts := map[string]int{}
for rank, entry := range ranked {
reason, label, kind, genre, evidence := explainPreparedRecommendation(
profile, entry.item, compatibility, browsed[entry.item.ID], evidenceByGenre,
completedReasonCounts,
)
prepared = append(prepared, PreparedCandidate{
ItemID: entry.item.ID,
BaseRank: rank + 1,
BaseScore: entry.score,
RuntimeMinutes: entry.item.RuntimeMinutes(),
AffinityScore: entry.base,
CompatibilityScore: entry.compatibility,
CompatibilityLabel: label,
ReasonKind: kind,
ReasonGenre: genre,
ReasonSourceSessionID: evidence.SessionID,
ReasonSourceItemID: evidence.ItemID,
ReasonSourceTitle: evidence.Title,
RecommendationReason: reason,
})
}
pickups := e.prepareAbandonedShows(ctx, cred, index, sessions, compatibility, time.Now())
if len(pickups) > 0 {
for i := range prepared {
prepared[i].BaseRank += len(pickups)
}
for i := range pickups {
pickups[i].BaseRank = i + 1
}
prepared = append(pickups, prepared...)
}
meanCompletion := 0.0
if len(sessions) > 0 {
meanCompletion = completionTotal / float64(len(sessions))
}
codecs := map[string]map[string]int{
"direct": compatibility.directCodecs,
"transcode": compatibility.transcodeCodecs,
}
return PreparedResult{
Profile: PreparedProfile{
TracearrUserID: tracearrUserID, TracearrUsername: tracearrUsername,
SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion,
TypicalSessionMinutes: medianInt(durations),
GenreAffinity: profile.GenreWeights, TitleAffinity: titleAffinity,
StudioAffinity: profile.StudioWeights, CodecOutcomes: codecs,
SignalsThrough: signalsThrough,
},
Candidates: prepared,
Mappings: mappings,
}, nil
}
const (
abandonedShowAge = 21 * 24 * time.Hour
maxPickupShows = 20
)
type abandonedShowProgress struct {
item Item
lastActivity time.Time
lastSeason int
completedEpisodes map[string]bool
}
// prepareAbandonedShows adds watched series back into the otherwise-unwatched candidate
// pool. Emby Next Up is the completion boundary: if Emby has no next episode for this
// user, the show is complete and cannot appear here.
func (e *Engine) prepareAbandonedShows(
ctx context.Context,
cred emby.Credentials,
index catalogueIndex,
sessions []tracearr.Session,
compatibility compatibilityProfile,
now time.Time,
) []PreparedCandidate {
nextUpSource, ok := e.source.(NextUpSource)
if !ok {
return nil
}
result, err := nextUpSource.NextUp(ctx, cred, url.Values{
"Limit": {"5000"},
"Fields": {"SeriesName,SeriesId,ParentIndexNumber,IndexNumber,RunTimeTicks"},
"EnableImages": {"false"},
"EnableUserData": {"true"},
"EnableTotalRecordCount": {"false"},
})
if err != nil {
e.log.Warn("could not check abandoned shows against Emby Next Up", "error", err)
return nil
}
return abandonedShowCandidates(index, sessions, Decode(result.Items), compatibility, now)
}
func abandonedShowCandidates(
index catalogueIndex,
sessions []tracearr.Session,
nextUp []Item,
compatibility compatibilityProfile,
now time.Time,
) []PreparedCandidate {
progress := map[string]*abandonedShowProgress{}
for _, session := range sessions {
if !strings.EqualFold(session.MediaType, "episode") ||
strings.TrimSpace(session.ShowTitle) == "" ||
session.Completion() < 0.1 {
continue
}
series, matched := index.match(session)
if !matched || series.ID == "" || !strings.EqualFold(series.Type, "Series") {
continue
}
activity, ok := tracearrActivityTime(session)
if !ok {
continue
}
current := progress[series.ID]
if current == nil {
current = &abandonedShowProgress{
item: series, completedEpisodes: map[string]bool{},
}
progress[series.ID] = current
}
if activity.After(current.lastActivity) {
current.lastActivity = activity
if session.SeasonNumber != nil {
current.lastSeason = *session.SeasonNumber
}
}
if session.Completion() >= 0.9 && session.SeasonNumber != nil &&
session.EpisodeNumber != nil {
key := strconv.Itoa(*session.SeasonNumber) + ":" + strconv.Itoa(*session.EpisodeNumber)
current.completedEpisodes[key] = true
}
}
nextBySeries := map[string]Item{}
for _, episode := range nextUp {
// Specials do not mean the main programme is unfinished.
if episode.SeriesID == "" || episode.ParentIndexNumber <= 0 {
continue
}
if _, exists := nextBySeries[episode.SeriesID]; !exists {
nextBySeries[episode.SeriesID] = episode
}
}
type pickup struct {
progress *abandonedShowProgress
next Item
}
eligible := make([]pickup, 0, len(progress))
cutoff := now.Add(-abandonedShowAge)
for seriesID, watched := range progress {
next, unfinished := nextBySeries[seriesID]
if !unfinished || watched.lastActivity.After(cutoff) {
continue
}
eligible = append(eligible, pickup{progress: watched, next: next})
}
sort.SliceStable(eligible, func(i, j int) bool {
iLaterSeason := eligible[i].progress.lastSeason > 1
jLaterSeason := eligible[j].progress.lastSeason > 1
if iLaterSeason != jLaterSeason {
return iLaterSeason
}
if !eligible[i].progress.lastActivity.Equal(eligible[j].progress.lastActivity) {
return eligible[i].progress.lastActivity.After(eligible[j].progress.lastActivity)
}
return len(eligible[i].progress.completedEpisodes) >
len(eligible[j].progress.completedEpisodes)
})
if len(eligible) > maxPickupShows {
eligible = eligible[:maxPickupShows]
}
out := make([]PreparedCandidate, 0, len(eligible))
for _, candidate := range eligible {
watched := candidate.progress
next := candidate.next
reason := abandonedShowReason(watched.lastSeason, next.ParentIndexNumber)
compatibilityValue := compatibilityScore(watched.item, compatibility)
out = append(out, PreparedCandidate{
ItemID: watched.item.ID,
RuntimeMinutes: next.RuntimeMinutes(),
BaseScore: float64(len(watched.completedEpisodes)),
AffinityScore: float64(len(watched.completedEpisodes)),
CompatibilityScore: compatibilityValue,
CompatibilityLabel: compatibilityLabelForScore(compatibilityValue),
ReasonKind: "pick-up",
ReasonSourceItemID: next.ID,
ReasonSourceTitle: next.Name,
RecommendationReason: reason,
})
}
return out
}
func tracearrActivityTime(session tracearr.Session) (time.Time, bool) {
if stopped, ok := parseTracearrTime(session.StoppedAt); ok {
return stopped, true
}
return parseTracearrTime(session.StartedAt)
}
func abandonedShowReason(lastSeason, nextSeason int) string {
switch {
case lastSeason == 1 && nextSeason > 1:
return fmt.Sprintf("You finished season 1 · season %d is waiting", nextSeason)
case lastSeason > 1 && nextSeason > lastSeason:
return fmt.Sprintf("You made it through season %d · season %d is waiting", lastSeason, nextSeason)
case lastSeason > 1:
return fmt.Sprintf("You made it to season %d · pick it up again", lastSeason)
case lastSeason == 1:
return "You left this in season 1 · pick it up again"
default:
return "You left this unfinished · pick it up again"
}
}
func compatibilityLabelForScore(score float64) string {
switch {
case score > 0.2:
return "Direct plays well on this TV"
case score < -0.2:
return "May need transcoding on this TV"
default:
return "TV compatibility not yet learned"
}
}
func explainPreparedRecommendation(
profile Profile,
item Item,
compatibility compatibilityProfile,
browsed bool,
evidenceByGenre map[string][]PreparedEvidence,
completedReasonCounts map[string]int,
) (reason, label, kind, genre string, evidence PreparedEvidence) {
for _, wanted := range profile.TopGenres(5) {
for _, candidateGenre := range item.Genres {
if strings.EqualFold(wanted, candidateGenre) {
genre = candidateGenre
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))]
strong := make([]PreparedEvidence, 0, len(options))
for _, option := range options {
if strongEvidenceMatch(item, option) {
strong = append(strong, option)
}
}
if len(strong) > 0 {
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
}
break
}
}
if genre != "" {
break
}
}
// Keep specific evidence prominent without letting it monopolise a row. One third
// of otherwise eligible cards deliberately uses the broader genre explanation,
// and no completed title can explain more than four candidates in a prepared pool.
useCompleted := evidence.Title != "" &&
stableEvidenceIndex("reason-kind:"+item.ID, 3) != 0 &&
completedReasonCounts[evidence.ItemID] < 4
switch {
case browsed:
reason, kind = "You explored this recently", "browsed"
evidence = PreparedEvidence{}
case useCompleted:
reason, kind = "Because you finished "+evidence.Title, "completed-title"
completedReasonCounts[evidence.ItemID]++
case genre != "":
reason, kind = "Matches your "+genre+" viewing", "genre"
evidence = PreparedEvidence{}
case len(profile.Seeds) > 0:
reason, kind = "Inspired by "+profile.Seeds[0].Name, "recent-title"
evidence = PreparedEvidence{}
default:
reason, kind = "Matches your recent viewing", "generic"
evidence = PreparedEvidence{}
}
switch score := compatibilityScore(item, compatibility); {
case score > 0.2:
label = "Direct plays well on this TV"
reason += " · " + label
case score < -0.2:
label = "May need transcoding on this TV"
default:
label = "TV compatibility not yet learned"
}
return reason, label, kind, genre, evidence
}
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
shared := 0
broadOnly := true
for _, candidateGenre := range item.Genres {
for _, evidenceGenre := range evidence.Genres {
if !strings.EqualFold(strings.TrimSpace(candidateGenre), strings.TrimSpace(evidenceGenre)) {
continue
}
shared++
switch strings.ToLower(strings.TrimSpace(candidateGenre)) {
case "action", "adventure", "comedy", "drama", "thriller":
default:
broadOnly = false
}
break
}
}
return shared >= 2 || shared == 1 && !broadOnly
}
func stableEvidenceIndex(itemID string, size int) int {
if size <= 1 {
return 0
}
var hash uint32 = 2166136261
for _, value := range []byte(itemID) {
hash ^= uint32(value)
hash *= 16777619
}
return int(hash % uint32(size))
}
type catalogueIndex struct {
movieExact map[string]Item
movieLoose map[string]Item
series map[string]Item
ambiguous map[string]bool
}
func newCatalogueIndex(items []Item) catalogueIndex {
index := catalogueIndex{
movieExact: map[string]Item{}, movieLoose: map[string]Item{},
series: map[string]Item{}, ambiguous: map[string]bool{},
}
for _, item := range items {
key := normalizePreparedTitle(item.Name)
switch item.Type {
case "Movie":
if item.ProductionYear > 0 {
index.movieExact[key+"|"+itoa(item.ProductionYear)] = item
}
if _, exists := index.movieLoose[key]; exists {
index.ambiguous["movie|"+key] = true
} else {
index.movieLoose[key] = item
}
case "Series":
if _, exists := index.series[key]; exists {
index.ambiguous["series|"+key] = true
} else {
index.series[key] = item
}
}
}
return index
}
func (i catalogueIndex) match(session tracearr.Session) (Item, bool) {
if strings.EqualFold(session.MediaType, "episode") && strings.TrimSpace(session.ShowTitle) != "" {
key := normalizePreparedTitle(session.ShowTitle)
if i.ambiguous["series|"+key] {
return Item{}, false
}
item, ok := i.series[key]
return item, ok
}
key := normalizePreparedTitle(session.MediaTitle)
if session.Year != nil && *session.Year > 0 {
if item, ok := i.movieExact[key+"|"+itoa(*session.Year)]; ok {
return item, true
}
}
if i.ambiguous["movie|"+key] {
return Item{}, false
}
item, ok := i.movieLoose[key]
return item, ok
}
func normalizePreparedTitle(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
func parseTracearrTime(value string) (time.Time, bool) {
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
if err != nil {
return time.Time{}, false
}
return parsed.UTC(), true
}
func medianInt(values []int) int {
if len(values) == 0 {
return 0
}
copyValues := append([]int(nil), values...)
sort.Ints(copyValues)
mid := len(copyValues) / 2
if len(copyValues)%2 == 1 {
return copyValues[mid]
}
return (copyValues[mid-1] + copyValues[mid]) / 2
}
func itoa(value int) string {
if value == 0 {
return "0"
}
var buf [20]byte
pos := len(buf)
for value > 0 {
pos--
buf[pos] = byte('0' + value%10)
value /= 10
}
return string(buf[pos:])
}
+155
View File
@@ -0,0 +1,155 @@
// Package sonarr provides the small read-only slice of Sonarr used by the home screen.
package sonarr
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
type Image struct {
CoverType string `json:"coverType"`
URL string `json:"url"`
RemoteURL string `json:"remoteUrl"`
}
type Series struct {
ID int `json:"id"`
Title string `json:"title"`
Overview string `json:"overview"`
Year int `json:"year"`
Network string `json:"network"`
Genres []string `json:"genres"`
Images []Image `json:"images"`
}
type EpisodeFile struct {
DateAdded *time.Time `json:"dateAdded"`
}
type Episode struct {
ID int `json:"id"`
SeriesID int `json:"seriesId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"`
Overview string `json:"overview"`
AirDateUTC *time.Time `json:"airDateUtc"`
Runtime int `json:"runtime"`
HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"`
Grabbed bool `json:"grabbed"`
Series Series `json:"series"`
EpisodeFile *EpisodeFile `json:"episodeFile"`
}
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("sonarr: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL, apiKey string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// Calendar returns episodes in [start, end), including series artwork and imported-file
// details so Memby can distinguish upcoming, downloading and already-added episodes.
func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Episode, error) {
params := url.Values{
"start": {start.UTC().Format(time.RFC3339Nano)},
"end": {end.UTC().Format(time.RFC3339Nano)},
"unmonitored": {"true"},
"includeSeries": {"true"},
"includeEpisodeFile": {"true"},
"includeEpisodeImages": {"true"},
}
req, err := c.request(ctx, "/api/v3/calendar", params)
if err != nil {
return nil, err
}
var episodes []Episode
if err := c.do(req, &episodes); err != nil {
return nil, err
}
return episodes, nil
}
// MediaCover fetches a series poster or fanart without exposing the Sonarr API key.
func (c *Client) MediaCover(ctx context.Context, seriesID int, coverType string) (*http.Response, error) {
if seriesID <= 0 || (coverType != "poster" && coverType != "fanart") {
return nil, fmt.Errorf("sonarr: invalid media cover")
}
path := "/MediaCover/" + strconv.Itoa(seriesID) + "/" + coverType + ".jpg"
req, err := c.request(ctx, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "image/*")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("sonarr: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return nil, &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
return resp, nil
}
func (c *Client) request(ctx context.Context, path string, params url.Values) (*http.Request, error) {
endpoint := c.baseURL + path
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
return req, nil
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("sonarr: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("sonarr: decode response: %w", err)
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package sonarr
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
var gotQuery string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/calendar" {
t.Errorf("path = %q", r.URL.Path)
}
if got := r.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q", got)
}
if r.URL.Query().Get("includeSeries") != "true" ||
r.URL.Query().Get("includeEpisodeFile") != "true" {
t.Errorf("missing include flags: %s", r.URL.RawQuery)
}
gotQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":7,"seriesId":2,"title":"Arrival","hasFile":true}]`))
}))
defer upstream.Close()
client := New(upstream.URL, "secret", time.Second)
episodes, err := client.Calendar(
context.Background(),
time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC),
time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatal(err)
}
if len(episodes) != 1 || episodes[0].ID != 7 || !episodes[0].HasFile {
t.Fatalf("unexpected episodes: %+v", episodes)
}
if gotQuery == "" || rHasAPIKey(gotQuery) {
t.Fatalf("API key leaked into query: %q", gotQuery)
}
}
func rHasAPIKey(query string) bool {
return strings.Contains(query, "secret")
}
+602
View File
@@ -0,0 +1,602 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
const tracearrImportStateKey = "tracearr_import_state"
type TracearrSessionKey struct {
ServerID string
SessionID string
}
type TracearrSession struct {
ServerID string
SessionID string
UserID string
Username string
State string
MediaType string
MediaTitle string
ShowTitle string
SeasonNumber *int
EpisodeNumber *int
ProductionYear *int
StartedAt *time.Time
StoppedAt *time.Time
DurationMs int64
ProgressMs int64
TotalDurationMs int64
Watched bool
Device string
Player string
Product string
Platform string
IsTranscode bool
VideoDecision string
AudioDecision string
SourceVideoCodec string
SourceAudioCodec string
EmbyItemID string
EmbySeriesID string
SourceFingerprint []byte
}
type TracearrImportState struct {
LastIncrementalAt *time.Time `json:"lastIncrementalAt,omitempty"`
LastFullAt *time.Time `json:"lastFullAt,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type RecommendationProfile struct {
EmbyUserID string
TracearrUserID string
TracearrUsername string
SourceSessionCount int
MeanCompletionRatio float64
TypicalSessionMinutes int
GenreAffinity json.RawMessage
TitleAffinity json.RawMessage
StudioAffinity json.RawMessage
CodecOutcomes json.RawMessage
SignalsThrough *time.Time
BuiltAt time.Time
}
type ForYouCandidate struct {
ItemID string
BaseRank int
BaseScore float64
RuntimeMinutes int
AffinityScore float64
CompatibilityScore float64
CompatibilityLabel string
ReasonKind string
ReasonGenre string
ReasonSourceSessionID string
ReasonSourceItemID string
ReasonSourceTitle string
RecommendationReason string
}
type PreparedForYouItem struct {
ItemID string
BaseRank int
Payload json.RawMessage
RuntimeMinutes int
CompatibilityScore float64
CompatibilityLabel string
RecommendationReason string
ReasonKind string
ReasonGenre string
ReasonSourceItemID string
ReasonSourceTitle string
}
type ForYouStats struct {
TracearrSessions int64 `json:"tracearrSessions"`
Profiles int64 `json:"profiles"`
Candidates int64 `json:"candidates"`
LastFullImport *time.Time `json:"lastFullImport,omitempty"`
}
func (s *Store) TracearrSessionCount(ctx context.Context) (int64, error) {
var count int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM tracearr_sessions`).Scan(&count); err != nil {
return 0, fmt.Errorf("store: count tracearr sessions: %w", err)
}
return count, nil
}
func (s *Store) TracearrFingerprints(
ctx context.Context,
keys []TracearrSessionKey,
) (map[TracearrSessionKey][]byte, error) {
out := make(map[TracearrSessionKey][]byte, len(keys))
if len(keys) == 0 {
return out, nil
}
servers := make([]string, 0, len(keys))
ids := make([]string, 0, len(keys))
for _, key := range keys {
servers = append(servers, key.ServerID)
ids = append(ids, key.SessionID)
}
rows, err := s.pool.Query(ctx, `
SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint
FROM tracearr_sessions current
JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
ON current.server_id = wanted.server_id
AND current.tracearr_session_id = wanted.session_id`,
servers, ids)
if err != nil {
return nil, fmt.Errorf("store: tracearr fingerprints: %w", err)
}
defer rows.Close()
for rows.Next() {
var key TracearrSessionKey
var fingerprint []byte
if err := rows.Scan(&key.ServerID, &key.SessionID, &fingerprint); err != nil {
return nil, err
}
out[key] = fingerprint
}
return out, rows.Err()
}
func (s *Store) UpsertTracearrSessions(
ctx context.Context,
sessions []TracearrSession,
seenAt time.Time,
) error {
if len(sessions) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, session := range sessions {
batch.Queue(`
INSERT INTO tracearr_sessions (
server_id, tracearr_session_id, tracearr_user_id, username, state,
media_type, media_title, show_title, season_number, episode_number,
production_year, started_at, stopped_at, duration_ms, progress_ms,
total_duration_ms, watched, device, player, product, platform,
is_transcode, video_decision, audio_decision, source_video_codec,
source_audio_codec, emby_item_id, emby_series_id, source_fingerprint,
source_seen_at, imported_at, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,now(),now()
)
ON CONFLICT (server_id, tracearr_session_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
username = EXCLUDED.username,
state = EXCLUDED.state,
media_type = EXCLUDED.media_type,
media_title = EXCLUDED.media_title,
show_title = EXCLUDED.show_title,
season_number = EXCLUDED.season_number,
episode_number = EXCLUDED.episode_number,
production_year = EXCLUDED.production_year,
started_at = EXCLUDED.started_at,
stopped_at = EXCLUDED.stopped_at,
duration_ms = EXCLUDED.duration_ms,
progress_ms = EXCLUDED.progress_ms,
total_duration_ms = EXCLUDED.total_duration_ms,
watched = EXCLUDED.watched,
device = EXCLUDED.device,
player = EXCLUDED.player,
product = EXCLUDED.product,
platform = EXCLUDED.platform,
is_transcode = EXCLUDED.is_transcode,
video_decision = EXCLUDED.video_decision,
audio_decision = EXCLUDED.audio_decision,
source_video_codec = EXCLUDED.source_video_codec,
source_audio_codec = EXCLUDED.source_audio_codec,
source_fingerprint = EXCLUDED.source_fingerprint,
source_seen_at = EXCLUDED.source_seen_at,
updated_at = CASE
WHEN tracearr_sessions.source_fingerprint IS DISTINCT FROM EXCLUDED.source_fingerprint
THEN now() ELSE tracearr_sessions.updated_at END`,
session.ServerID, session.SessionID, session.UserID, session.Username,
session.State, session.MediaType, session.MediaTitle, session.ShowTitle,
session.SeasonNumber, session.EpisodeNumber, session.ProductionYear,
session.StartedAt, session.StoppedAt, session.DurationMs, session.ProgressMs,
session.TotalDurationMs, session.Watched, session.Device, session.Player,
session.Product, session.Platform, session.IsTranscode, session.VideoDecision,
session.AudioDecision, session.SourceVideoCodec, session.SourceAudioCodec,
session.EmbyItemID, session.EmbySeriesID, session.SourceFingerprint, seenAt)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
for range sessions {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("store: upsert tracearr sessions: %w", err)
}
}
return nil
}
func (s *Store) DeleteTracearrSessionsNotSeenSince(
ctx context.Context,
serverID string,
cutoff time.Time,
) (int64, error) {
if serverID == "" {
tag, err := s.pool.Exec(ctx, `DELETE FROM tracearr_sessions WHERE source_seen_at < $1`, cutoff)
if err != nil {
return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
}
return tag.RowsAffected(), nil
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM tracearr_sessions WHERE server_id = $1 AND source_seen_at < $2`,
serverID, cutoff)
if err != nil {
return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *Store) TracearrSessionsForUser(
ctx context.Context,
tracearrUserID, username string,
) ([]TracearrSession, error) {
rows, err := s.pool.Query(ctx, `
SELECT server_id, tracearr_session_id, tracearr_user_id, username, state,
media_type, media_title, show_title, season_number, episode_number,
production_year, started_at, stopped_at, duration_ms, progress_ms,
total_duration_ms, watched, device, player, product, platform,
is_transcode, video_decision, audio_decision, source_video_codec,
source_audio_codec, emby_item_id, emby_series_id, source_fingerprint
FROM tracearr_sessions
WHERE ($1 <> '' AND tracearr_user_id = $1)
OR ($1 = '' AND lower(username) = lower($2))
ORDER BY started_at DESC NULLS LAST, tracearr_session_id DESC`,
tracearrUserID, username)
if err != nil {
return nil, fmt.Errorf("store: tracearr user sessions: %w", err)
}
defer rows.Close()
out := []TracearrSession{}
for rows.Next() {
var session TracearrSession
if err := rows.Scan(
&session.ServerID, &session.SessionID, &session.UserID, &session.Username,
&session.State, &session.MediaType, &session.MediaTitle, &session.ShowTitle,
&session.SeasonNumber, &session.EpisodeNumber, &session.ProductionYear,
&session.StartedAt, &session.StoppedAt, &session.DurationMs, &session.ProgressMs,
&session.TotalDurationMs, &session.Watched, &session.Device, &session.Player,
&session.Product, &session.Platform, &session.IsTranscode,
&session.VideoDecision, &session.AudioDecision, &session.SourceVideoCodec,
&session.SourceAudioCodec, &session.EmbyItemID, &session.EmbySeriesID,
&session.SourceFingerprint,
); err != nil {
return nil, err
}
out = append(out, session)
}
return out, rows.Err()
}
func (s *Store) UpdateTracearrSessionMapping(
ctx context.Context,
key TracearrSessionKey,
itemID, seriesID string,
) error {
_, err := s.pool.Exec(ctx, `
UPDATE tracearr_sessions
SET emby_item_id = $3, emby_series_id = $4
WHERE server_id = $1 AND tracearr_session_id = $2
AND (emby_item_id, emby_series_id) IS DISTINCT FROM ($3, $4)`,
key.ServerID, key.SessionID, itemID, seriesID)
if err != nil {
return fmt.Errorf("store: map tracearr session: %w", err)
}
return nil
}
func (s *Store) ForYouTracearrIdentity(ctx context.Context, userID string) (string, string, error) {
var tracearrUserID, username string
err := s.pool.QueryRow(ctx, `
SELECT tracearr_user_id, tracearr_username
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
Scan(&tracearrUserID, &username)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("store: For You Tracearr identity: %w", err)
}
return tracearrUserID, username, nil
}
func (s *Store) TracearrImportState(ctx context.Context) (TracearrImportState, error) {
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, tracearrImportStateKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return TracearrImportState{}, nil
}
if err != nil {
return TracearrImportState{}, fmt.Errorf("store: read tracearr import state: %w", err)
}
var state TracearrImportState
if err := json.Unmarshal(raw, &state); err != nil {
return state, fmt.Errorf("store: decode tracearr import state: %w", err)
}
return state, nil
}
func (s *Store) SetTracearrImportState(ctx context.Context, state TracearrImportState) error {
raw, err := json.Marshal(state)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
tracearrImportStateKey, string(raw))
if err != nil {
return fmt.Errorf("store: write tracearr import state: %w", err)
}
return nil
}
func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (emby_user_id)
token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, last_seen_at
FROM sessions
ORDER BY emby_user_id, last_seen_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: active recommendation users: %w", err)
}
defer rows.Close()
out := []Session{}
for rows.Next() {
var session Session
if err := rows.Scan(
&session.TokenHash, &session.EmbyUserID, &session.EmbyToken, &session.Username,
&session.ServerID, &session.DeviceID, &session.DeviceName, &session.ClientVersion,
&session.ClientProtocol, &session.LastSeenAt,
); err != nil {
return nil, err
}
out = append(out, session)
}
return out, rows.Err()
}
func (s *Store) MarkForYouDirty(ctx context.Context, userID, username string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (emby_user_id, tracearr_username, dirty_since)
VALUES ($1, $2, now())
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_username = CASE WHEN $2 <> '' THEN $2 ELSE recommendation_user_profiles.tracearr_username END,
dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
userID, username)
if err != nil {
return fmt.Errorf("store: mark For You dirty: %w", err)
}
return nil
}
func (s *Store) MarkAllForYouProfilesDirty(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `
UPDATE recommendation_user_profiles
SET dirty_since = coalesce(dirty_since, now())`)
if err != nil {
return fmt.Errorf("store: mark all For You profiles dirty: %w", err)
}
return nil
}
func (s *Store) MatchRecommendationUser(
ctx context.Context,
embyUserID, embyUsername, tracearrUserID, tracearrUsername string,
) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (
emby_user_id, tracearr_user_id, tracearr_username, dirty_since
) VALUES ($1, $2, CASE WHEN $3 <> '' THEN $3 ELSE $4 END, now())
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
tracearr_username = CASE
WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username
ELSE $4
END,
dirty_since = CASE
WHEN recommendation_user_profiles.tracearr_user_id IS DISTINCT FROM EXCLUDED.tracearr_user_id
OR recommendation_user_profiles.tracearr_username IS DISTINCT FROM
CASE WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username ELSE $4 END
THEN coalesce(recommendation_user_profiles.dirty_since, now())
ELSE recommendation_user_profiles.dirty_since
END`,
embyUserID, tracearrUserID, tracearrUsername, embyUsername)
if err != nil {
return fmt.Errorf("store: match recommendation user: %w", err)
}
return nil
}
func (s *Store) ForYouProfileTimes(
ctx context.Context,
userID string,
) (builtAt, poolBuiltAt, dirtySince *time.Time, err error) {
err = s.pool.QueryRow(ctx, `
SELECT built_at, pool_built_at, dirty_since
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
Scan(&builtAt, &poolBuiltAt, &dirtySince)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil, nil
}
if err != nil {
return nil, nil, nil, fmt.Errorf("store: For You freshness: %w", err)
}
return builtAt, poolBuiltAt, dirtySince, nil
}
func (s *Store) SetForYouError(ctx context.Context, userID string, buildErr error) error {
message := ""
if buildErr != nil {
message = buildErr.Error()
}
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (emby_user_id, last_error, dirty_since)
VALUES ($1, $2, now())
ON CONFLICT (emby_user_id) DO UPDATE SET last_error = $2,
dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
userID, message)
return err
}
func (s *Store) ReplaceForYouPool(
ctx context.Context,
profile RecommendationProfile,
candidates []ForYouCandidate,
) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin For You rebuild: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, `
INSERT INTO recommendation_user_profiles (
emby_user_id, tracearr_user_id, tracearr_username, source_session_count,
mean_completion_ratio, typical_session_minutes, genre_affinity,
title_affinity, studio_affinity, codec_outcomes, signals_through,
built_at, pool_built_at, dirty_since, last_error
) VALUES (
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11,$12,$12,NULL,''
)
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
tracearr_username = EXCLUDED.tracearr_username,
source_session_count = EXCLUDED.source_session_count,
mean_completion_ratio = EXCLUDED.mean_completion_ratio,
typical_session_minutes = EXCLUDED.typical_session_minutes,
genre_affinity = EXCLUDED.genre_affinity,
title_affinity = EXCLUDED.title_affinity,
studio_affinity = EXCLUDED.studio_affinity,
codec_outcomes = EXCLUDED.codec_outcomes,
signals_through = EXCLUDED.signals_through,
built_at = EXCLUDED.built_at,
pool_built_at = EXCLUDED.pool_built_at,
dirty_since = NULL,
last_error = ''`,
profile.EmbyUserID, profile.TracearrUserID, profile.TracearrUsername,
profile.SourceSessionCount, profile.MeanCompletionRatio,
profile.TypicalSessionMinutes, string(profile.GenreAffinity),
string(profile.TitleAffinity), string(profile.StudioAffinity),
string(profile.CodecOutcomes), profile.SignalsThrough, profile.BuiltAt)
if err != nil {
return fmt.Errorf("store: write For You profile: %w", err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM for_you_candidates WHERE emby_user_id = $1`, profile.EmbyUserID); err != nil {
return fmt.Errorf("store: clear For You pool: %w", err)
}
batch := &pgx.Batch{}
for _, candidate := range candidates {
batch.Queue(`
INSERT INTO for_you_candidates (
emby_user_id, item_id, base_rank, base_score, runtime_minutes,
affinity_score, compatibility_score, compatibility_label, reason_kind,
reason_genre, reason_source_session_id, reason_source_item_id,
reason_source_title, recommendation_reason, built_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
profile.EmbyUserID, candidate.ItemID, candidate.BaseRank, candidate.BaseScore,
candidate.RuntimeMinutes, candidate.AffinityScore, candidate.CompatibilityScore,
candidate.CompatibilityLabel, candidate.ReasonKind, candidate.ReasonGenre,
candidate.ReasonSourceSessionID, candidate.ReasonSourceItemID,
candidate.ReasonSourceTitle, candidate.RecommendationReason, profile.BuiltAt)
}
results := tx.SendBatch(ctx, batch)
for range candidates {
if _, err := results.Exec(); err != nil {
_ = results.Close()
return fmt.Errorf("store: insert For You candidates: %w", err)
}
}
if err := results.Close(); err != nil {
return fmt.Errorf("store: close For You candidate batch: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit For You rebuild: %w", err)
}
return nil
}
func (s *Store) PreparedForYou(
ctx context.Context,
userID string,
minutes, limit int,
) ([]PreparedForYouItem, *time.Time, error) {
rows, err := s.pool.Query(ctx, `
SELECT fc.item_id, fc.base_rank, li.payload, fc.runtime_minutes,
fc.compatibility_score, fc.compatibility_label,
fc.recommendation_reason, fc.reason_kind, fc.reason_genre,
fc.reason_source_item_id, fc.reason_source_title, p.pool_built_at
FROM for_you_candidates fc
JOIN library_items li ON li.id = fc.item_id
JOIN recommendation_user_profiles p ON p.emby_user_id = fc.emby_user_id
WHERE fc.emby_user_id = $1
AND ($2 = 0 OR (fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2))
ORDER BY fc.base_rank
LIMIT $3`,
userID, minutes, limit)
if err != nil {
return nil, nil, fmt.Errorf("store: prepared For You: %w", err)
}
defer rows.Close()
out := []PreparedForYouItem{}
var builtAt *time.Time
for rows.Next() {
var payload []byte
var item PreparedForYouItem
var rowBuiltAt *time.Time
if err := rows.Scan(
&item.ItemID, &item.BaseRank, &payload, &item.RuntimeMinutes,
&item.CompatibilityScore, &item.CompatibilityLabel,
&item.RecommendationReason, &item.ReasonKind, &item.ReasonGenre,
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &rowBuiltAt,
); err != nil {
return nil, nil, err
}
item.Payload = json.RawMessage(payload)
out = append(out, item)
if builtAt == nil {
builtAt = rowBuiltAt
}
}
return out, builtAt, rows.Err()
}
func (s *Store) ForYouStats(ctx context.Context) (ForYouStats, error) {
var stats ForYouStats
if err := s.pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM tracearr_sessions),
(SELECT count(*) FROM recommendation_user_profiles),
(SELECT count(*) FROM for_you_candidates)`).
Scan(&stats.TracearrSessions, &stats.Profiles, &stats.Candidates); err != nil {
return stats, fmt.Errorf("store: For You stats: %w", err)
}
state, err := s.TracearrImportState(ctx)
if err != nil {
return stats, err
}
stats.LastFullImport = state.LastFullAt
return stats, nil
}
+288
View File
@@ -0,0 +1,288 @@
// Package tracearr reads per-user playback history from Tracearr's public API.
//
// Tracearr is deliberately kept behind the Memby gateway: its operator API key never
// reaches a television, and a Tracearr outage can only reduce recommendation quality.
package tracearr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode"
)
type Client struct {
baseURL string
apiKey string
serverID string
http *http.Client
}
type Session struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
State string `json:"state"`
MediaType string `json:"mediaType"`
MediaTitle string `json:"mediaTitle"`
ShowTitle string `json:"showTitle"`
SeasonNumber *int `json:"seasonNumber"`
EpisodeNumber *int `json:"episodeNumber"`
Year *int `json:"year"`
DurationMs FlexibleInt64 `json:"durationMs"`
ProgressMs FlexibleInt64 `json:"progressMs"`
TotalDurationMs FlexibleInt64 `json:"totalDurationMs"`
StartedAt string `json:"startedAt"`
StoppedAt string `json:"stoppedAt"`
Watched bool `json:"watched"`
Device string `json:"device"`
Player string `json:"player"`
Product string `json:"product"`
Platform string `json:"platform"`
IsTranscode bool `json:"isTranscode"`
VideoDecision string `json:"videoDecision"`
AudioDecision string `json:"audioDecision"`
SourceVideoCodec string `json:"sourceVideoCodec"`
SourceAudioCodec string `json:"sourceAudioCodec"`
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
}
// FlexibleInt64 accepts both JSON numbers and quoted integers. Tracearr currently
// serialises progress fields as strings while duration is numeric.
type FlexibleInt64 int64
func (v *FlexibleInt64) UnmarshalJSON(raw []byte) error {
raw = bytes.TrimSpace(raw)
if bytes.Equal(raw, []byte("null")) || len(raw) == 0 {
*v = 0
return nil
}
if raw[0] == '"' {
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return err
}
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return fmt.Errorf("tracearr integer %q: %w", value, err)
}
*v = FlexibleInt64(parsed)
return nil
}
parsed, err := strconv.ParseInt(string(raw), 10, 64)
if err != nil {
return fmt.Errorf("tracearr integer %q: %w", string(raw), err)
}
*v = FlexibleInt64(parsed)
return nil
}
type Page struct {
Data []Session `json:"data"`
Meta struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
} `json:"meta"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
SessionCount int `json:"sessionCount"`
}
type UserPage struct {
Data []User `json:"data"`
Meta struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
} `json:"meta"`
}
func New(baseURL, apiKey, serverID string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
apiKey: strings.TrimSpace(apiKey),
serverID: strings.TrimSpace(serverID),
http: &http.Client{Timeout: timeout},
}
}
// History returns the newest sessions belonging to username. Tracearr's public history
// endpoint currently has no user filter, so Memby pages through the bounded recent
// window and filters locally. Usernames are the stable identity shared by Emby and
// Tracearr; no fuzzy matching is used.
func (c *Client) History(ctx context.Context, username string, limit int) ([]Session, error) {
if c == nil || c.baseURL == "" || c.apiKey == "" || strings.TrimSpace(username) == "" {
return nil, nil
}
if limit <= 0 {
limit = 100
}
const pageSize = 100
const maxPages = 10
wanted := strings.TrimSpace(username)
out := make([]Session, 0, min(limit, pageSize))
for pageNumber := 1; pageNumber <= maxPages && len(out) < limit; pageNumber++ {
historyPage, err := c.Page(ctx, pageNumber, pageSize)
if err != nil {
return nil, err
}
for _, session := range historyPage.Data {
if strings.EqualFold(strings.TrimSpace(session.User.Username), wanted) {
out = append(out, session)
if len(out) == limit {
break
}
}
}
if pageNumber*pageSize >= historyPage.Meta.Total || len(historyPage.Data) == 0 {
break
}
}
return out, nil
}
// Page reads one public-history page. Imports use this directly so Tracearr pagination
// happens in the background rather than while a television waits.
func (c *Client) Page(ctx context.Context, pageNumber, pageSize int) (Page, error) {
endpoint, err := c.publicEndpoint("/api/v1/public/history", pageNumber, pageSize)
if err != nil {
return Page{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return Page{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return Page{}, fmt.Errorf("tracearr history: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return Page{}, fmt.Errorf("tracearr history: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result Page
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&result); err != nil {
return Page{}, fmt.Errorf("tracearr history: decode: %w", err)
}
return result, nil
}
// Users lists the Tracearr identities known to the configured media server. Household
// preparation uses exact username matches against Emby; no fuzzy identity guesses are
// made here.
func (c *Client) Users(ctx context.Context, pageNumber, pageSize int) (UserPage, error) {
endpoint, err := c.publicEndpoint("/api/v1/public/users", pageNumber, pageSize)
if err != nil {
return UserPage{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return UserPage{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return UserPage{}, fmt.Errorf("tracearr users: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return UserPage{}, fmt.Errorf(
"tracearr users: status %d: %s",
resp.StatusCode, strings.TrimSpace(string(body)),
)
}
var result UserPage
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
return UserPage{}, fmt.Errorf("tracearr users: decode: %w", err)
}
return result, nil
}
func (c *Client) publicEndpoint(path string, pageNumber, pageSize int) (*url.URL, error) {
endpoint, err := url.Parse(c.baseURL + path)
if err != nil {
return nil, err
}
query := endpoint.Query()
query.Set("page", strconv.Itoa(pageNumber))
query.Set("pageSize", strconv.Itoa(pageSize))
if c.serverID != "" {
query.Set("serverId", c.serverID)
}
endpoint.RawQuery = query.Encode()
return endpoint, nil
}
func (c *Client) ConfiguredServerID() string {
if c == nil {
return ""
}
return c.serverID
}
// Completion is the useful fraction of a session. Tracearr's watched flag wins; for an
// interrupted play, progress is preferred and aggregate watch time is the fallback.
func (s Session) Completion() float64 {
if s.Watched {
return 1
}
if s.TotalDurationMs <= 0 {
return 0
}
progress := s.ProgressMs
if s.DurationMs > progress {
progress = s.DurationMs
}
value := float64(progress) / float64(s.TotalDurationMs)
if value < 0 {
return 0
}
if value > 1 {
return 1
}
return value
}
func (s Session) TitleKey() string {
if strings.EqualFold(s.MediaType, "episode") && strings.TrimSpace(s.ShowTitle) != "" {
return normalizeTitle(s.ShowTitle)
}
return normalizeTitle(s.MediaTitle)
}
func (s Session) IsTelevisionSession() bool {
value := strings.ToLower(strings.Join([]string{s.Device, s.Player, s.Product, s.Platform}, " "))
return strings.Contains(value, "tv") ||
strings.Contains(value, "android") ||
strings.Contains(value, "memby")
}
func normalizeTitle(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
+93
View File
@@ -0,0 +1,93 @@
package tracearr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestFlexiblePlaybackNumbersAcceptTracearrStrings(t *testing.T) {
var session Session
if err := json.Unmarshal([]byte(`{
"durationMs":1234,
"progressMs":"900",
"totalDurationMs":"1800"
}`), &session); err != nil {
t.Fatal(err)
}
if session.DurationMs != 1234 || session.ProgressMs != 900 || session.TotalDurationMs != 1800 {
t.Fatalf("unexpected playback values: %+v", session)
}
}
func TestHistoryFiltersExactUserAndSendsBearerToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer trr_pub_secret" {
t.Fatalf("authorization = %q", got)
}
if got := r.URL.Query().Get("serverId"); got != "server-1" {
t.Fatalf("serverId = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data": [
{"mediaTitle":"Arrival","mediaType":"movie","watched":true,"user":{"id":"1","username":"Matt"}},
{"mediaTitle":"Alien","mediaType":"movie","watched":true,"user":{"id":"2","username":"Other"}}
],
"meta":{"total":2,"page":1,"pageSize":100}
}`))
}))
defer server.Close()
client := New(server.URL, "trr_pub_secret", "server-1", time.Second)
history, err := client.History(context.Background(), "matt", 20)
if err != nil {
t.Fatal(err)
}
if len(history) != 1 || history[0].MediaTitle != "Arrival" {
t.Fatalf("history = %+v", history)
}
}
func TestCompletionAndTelevisionDetection(t *testing.T) {
session := Session{ProgressMs: 45, TotalDurationMs: 100, Platform: "Android TV"}
if got := session.Completion(); got != .45 {
t.Fatalf("completion = %v", got)
}
if !session.IsTelevisionSession() {
t.Fatal("expected Android TV session")
}
session.Watched = true
if got := session.Completion(); got != 1 {
t.Fatalf("watched completion = %v", got)
}
}
func TestUsersUsesPublicUsersEndpointAndServerScope(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/public/users" {
t.Fatalf("path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("serverId"); got != "server-1" {
t.Fatalf("serverId = %q", got)
}
_, _ = w.Write([]byte(`{
"data":[{"id":"trace-user","username":"Matt","sessionCount":385}],
"meta":{"total":1,"page":1,"pageSize":100}
}`))
}))
defer server.Close()
page, err := New(server.URL, "secret", "server-1", time.Second).
Users(context.Background(), 1, 100)
if err != nil {
t.Fatal(err)
}
if len(page.Data) != 1 || page.Data[0].Username != "Matt" ||
page.Data[0].SessionCount != 385 {
t.Fatalf("users = %+v", page.Data)
}
}