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