Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+107 -15
View File
@@ -6,6 +6,7 @@ import (
_ "embed"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
@@ -17,6 +18,8 @@ import (
//go:embed admin.html
var adminPage []byte
const adminCookieName = "memby_admin"
// adminRoutes is the operator interface: library imports, the maintenance switch, and
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// left exposed by accident.
@@ -26,7 +29,9 @@ func (s *Server) adminRoutes() http.Handler {
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
@@ -34,7 +39,22 @@ func (s *Server) adminRoutes() http.Handler {
return mux
}
// adminAuth guards the admin API with a shared token, compared in constant time.
func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
if s.events == nil {
writeJSON(w, http.StatusOK, map[string]any{
"events": []any{}, "next": 0, "oldest": 0, "latest": 0,
"dropped": 0, "hasMore": false,
})
return
}
after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
limit := queryInt(r, "limit", 500, 1000)
writeJSON(w, http.StatusOK, s.events.Events(after, limit))
}
// adminAuth guards the admin API with the shared token. Browser requests use the
// persistent HttpOnly cookie established by the admin page; automation can continue to
// send the token as a Bearer header.
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
@@ -42,6 +62,11 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return
}
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if presented == "" {
if cookie, err := r.Cookie(adminCookieName); err == nil {
presented = cookie.Value
}
}
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid admin token")
return
@@ -55,20 +80,30 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: s.cfg.AdminToken,
Path: "/admin",
MaxAge: 10 * 365 * 24 * 60 * 60,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// The page holds no secrets; the token is entered by the operator and kept in the
// browser's local storage.
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(adminPage)
}
type adminStatus struct {
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
ForYou store.ForYouStats `json:"forYou"`
ForYouRunning bool `json:"forYouRunning"`
}
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
@@ -87,13 +122,24 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
return
}
var forYouStats store.ForYouStats
forYouRunning := false
if s.forYou != nil {
forYouStats, err = s.forYou.Stats(ctx)
if err != nil {
s.log.Warn("For You stats failed", "error", err)
}
forYouRunning = s.forYou.Running()
}
writeJSON(w, http.StatusOK, adminStatus{
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
ForYou: forYouStats,
ForYouRunning: forYouRunning,
})
}
@@ -188,6 +234,52 @@ func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind})
}
type forYouAdminRequest struct {
Action string `json:"action"`
}
// handleAdminForYou provides the recovery controls needed for an idempotent backfill:
// import all Tracearr sessions again, or rebuild every active user's derived pool.
func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
if s.forYou == nil {
writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured")
return
}
var req forYouAdminRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
switch req.Action {
case "incremental-import", "full-import", "rebuild-all":
default:
writeError(w, http.StatusBadRequest,
`action must be "incremental-import", "full-import", or "rebuild-all"`)
return
}
if s.forYou.Running() {
writeError(w, http.StatusConflict, "For You maintenance is already running")
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), s.cfg.SyncTimeout)
defer cancel()
if req.Action != "rebuild-all" {
_, err := s.forYou.Import(ctx, req.Action == "full-import")
if err != nil {
s.log.Error("manual Tracearr import failed", "action", req.Action, "error", err)
return
}
}
if err := s.forYou.RebuildAll(ctx, true); err != nil {
s.log.Error("manual For You rebuild failed", "action", req.Action, "error", err)
}
}()
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "started", "action": req.Action,
})
}
type maintenanceRequest struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`
+182 -13
View File
@@ -15,7 +15,7 @@
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 980px; margin: 0 auto; display: grid; gap: 20px; }
main { width: 100%; margin: 0; display: grid; gap: 20px; }
h1 { font-size: 24px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
@@ -47,6 +47,32 @@
.muted { color: var(--muted); }
.banner { padding: 11px 14px; border-radius: 8px; background: #3a1d1c; color: #ffb3ad; display: none; }
.banner.show { display: block; }
.event-toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
.event-toolbar input[type=search] {
background:#0e1216; border:1px solid var(--line); border-radius:7px;
color:var(--text); padding:8px 10px; min-width:300px;
}
select {
background:#0e1216; color:var(--text); border:1px solid var(--line);
border-radius:7px; padding:8px;
}
.event-log {
height: min(58vh, 720px); min-height: 360px; overflow: auto; background:#090c0f;
border:1px solid var(--line); border-radius:7px; font:12px/1.45 ui-monospace, Consolas, monospace;
}
.event-line { display:grid; grid-template-columns:190px 62px 240px minmax(340px,1fr); gap:10px; padding:5px 9px; border-bottom:1px solid #171c21; }
.event-line:hover { background:#12171c; }
.event-time,.event-attrs { color:var(--muted); }
.event-level { font-weight:700; }
.event-level.ERROR { color:#ff8a80; }
.event-level.WARN { color:#f0c674; }
.event-level.DEBUG { color:#88a4bd; }
.event-empty { padding:20px; color:var(--muted); }
@media (max-width: 900px) {
body { padding:14px; }
.event-line { grid-template-columns:150px 55px 1fr; }
.event-attrs { grid-column:1 / -1; }
}
</style>
</head>
<body>
@@ -54,9 +80,6 @@
<header>
<h1>Memby admin</h1>
<span id="live" class="pill muted">connecting…</span>
<span style="flex:1"></span>
<input type="password" id="token" placeholder="Admin token" autocomplete="off">
<button id="save-token" class="secondary">Save</button>
</header>
<div id="error" class="banner"></div>
@@ -71,6 +94,17 @@
</div>
</section>
<section>
<h2>For You</h2>
<div class="stats" id="for-you-stats"><span class="muted">Loading…</span></div>
<div class="row">
<button id="for-you-import">Import recent sessions</button>
<button id="for-you-full" class="secondary">Full Tracearr backfill</button>
<button id="for-you-rebuild" class="secondary">Rebuild all pools</button>
<span class="muted" id="for-you-hint"></span>
</div>
</section>
<section>
<h2>Maintenance</h2>
<p class="muted" style="margin-top:0">
@@ -147,17 +181,29 @@
</table>
</div>
</section>
<section>
<h2>Live server events</h2>
<div class="event-toolbar">
<select id="event-level" aria-label="Minimum event level">
<option value="DEBUG">Debug and above</option>
<option value="INFO" selected>Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="event-search" placeholder="Filter message, path, version, status…">
<button id="event-pause" class="secondary">Pause</button>
<button id="event-clear" class="secondary">Clear view</button>
<button id="event-export" class="secondary">Export JSON</button>
<span id="event-stats" class="muted">Connecting…</span>
</div>
<div id="event-log" class="event-log" role="log" aria-live="polite">
<div class="event-empty">Waiting for server events…</div>
</div>
</section>
</main>
<script>
const tokenInput = document.getElementById('token');
tokenInput.value = localStorage.getItem('memby_admin_token') || '';
document.getElementById('save-token').addEventListener('click', () => {
localStorage.setItem('memby_admin_token', tokenInput.value.trim());
refresh();
});
function showError(message) {
const banner = document.getElementById('error');
banner.textContent = message || '';
@@ -168,7 +214,6 @@ async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
'Authorization': 'Bearer ' + (localStorage.getItem('memby_admin_token') || ''),
'Content-Type': 'application/json',
...(options.headers || {}),
},
@@ -193,6 +238,9 @@ function duration(ms) {
}
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
}[char]));
function renderStatus(status) {
const byType = status.library.byType || {};
@@ -211,6 +259,20 @@ function renderStatus(status) {
? 'Import running…'
: 'Automatic incremental import every ' + status.syncEvery + '.';
const forYou = status.forYou || {};
document.getElementById('for-you-stats').innerHTML =
'<div class="stat"><b>' + number(forYou.tracearrSessions) + '</b><span>Tracearr sessions</span></div>' +
'<div class="stat"><b>' + number(forYou.profiles) + '</b><span>user profiles</span></div>' +
'<div class="stat"><b>' + number(forYou.candidates) + '</b><span>ranked candidates</span></div>' +
'<div class="stat"><b style="font-size:15px">' + when(forYou.lastFullImport) +
'</b><span>last full import</span></div>';
const forYouRunning = Boolean(status.forYouRunning);
document.getElementById('for-you-import').disabled = forYouRunning;
document.getElementById('for-you-full').disabled = forYouRunning;
document.getElementById('for-you-rebuild').disabled = forYouRunning;
document.getElementById('for-you-hint').textContent =
forYouRunning ? 'For You maintenance running…' : 'Prepared pools normally refresh in the background.';
const maintenance = status.maintenance || {};
const state = document.getElementById('maintenance-state');
state.textContent = maintenance.enabled ? 'OFFLINE' : 'online';
@@ -311,6 +373,24 @@ document.getElementById('sync-full').addEventListener('click', () => {
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'full' }) }));
});
function forYouAction(action) {
return api('/admin/api/for-you', {
method: 'POST',
body: JSON.stringify({ action }),
});
}
document.getElementById('for-you-import').addEventListener('click', () =>
act(() => forYouAction('incremental-import')));
document.getElementById('for-you-full').addEventListener('click', () => {
if (!confirm('Backfill all Tracearr history and rebuild every active user pool?')) return;
act(() => forYouAction('full-import'));
});
document.getElementById('for-you-rebuild').addEventListener('click', () =>
act(() => forYouAction('rebuild-all')));
document.getElementById('maintenance-on').addEventListener('click', () => {
if (!confirm('Take Memby offline for every TV?')) return;
act(() => api('/admin/api/maintenance', {
@@ -346,8 +426,97 @@ document.getElementById('update-disable').addEventListener('click', () =>
document.getElementById('days').addEventListener('change', refresh);
const eventState = {
cursor: 0,
records: [],
dropped: 0,
paused: false,
fetching: false,
};
const eventRanks = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
function eventText(event) {
return [event.message, ...Object.entries(event.attributes || {}).flat()].join(' ').toLowerCase();
}
function renderEvents() {
const minimum = eventRanks[document.getElementById('event-level').value] || 20;
const search = document.getElementById('event-search').value.trim().toLowerCase();
const filtered = eventState.records.filter((event) =>
(eventRanks[event.level] || 0) >= minimum && (!search || eventText(event).includes(search)));
// Rendering every retained record at once can freeze a browser during an incident.
// Keep all records available for filtering/export, but virtualise the visible tail.
const visible = filtered.slice(-2500);
const log = document.getElementById('event-log');
const pinned = log.scrollHeight - log.scrollTop - log.clientHeight < 50;
log.innerHTML = visible.length ? visible.map((event) => {
const attrs = Object.entries(event.attributes || {})
.map(([key, value]) => escapeHtml(key) + '=' + escapeHtml(value)).join(' ');
return '<div class="event-line">' +
'<span class="event-time">' + escapeHtml(when(event.occurredAt)) + '</span>' +
'<span class="event-level ' + escapeHtml(event.level) + '">' + escapeHtml(event.level) + '</span>' +
'<span>' + escapeHtml(event.message) + '</span>' +
'<span class="event-attrs">' + attrs + '</span></div>';
}).join('') : '<div class="event-empty">No events match this filter.</div>';
if (pinned) log.scrollTop = log.scrollHeight;
document.getElementById('event-stats').textContent =
number(eventState.records.length) + ' retained · ' + number(filtered.length) + ' matching' +
(visible.length < filtered.length ? ' · showing latest ' + number(visible.length) : '') +
(eventState.dropped ? ' · ' + number(eventState.dropped) + ' overwritten before delivery' : '');
}
async function pollEvents() {
if (eventState.paused || eventState.fetching) return;
eventState.fetching = true;
try {
let pages = 0;
let page;
do {
page = await api('/admin/api/events?after=' + eventState.cursor + '&limit=1000');
eventState.cursor = page.next || eventState.cursor;
eventState.dropped += page.dropped || 0;
if ((page.events || []).length) {
eventState.records.push(...page.events);
if (eventState.records.length > 20000) {
eventState.records.splice(0, eventState.records.length - 20000);
}
}
pages += 1;
} while (page.hasMore && pages < 20 && !eventState.paused);
renderEvents();
showError('');
} catch (err) {
showError(err.message);
} finally {
eventState.fetching = false;
}
}
document.getElementById('event-pause').addEventListener('click', () => {
eventState.paused = !eventState.paused;
document.getElementById('event-pause').textContent = eventState.paused ? 'Resume' : 'Pause';
if (!eventState.paused) pollEvents();
});
document.getElementById('event-clear').addEventListener('click', () => {
eventState.records = [];
eventState.dropped = 0;
renderEvents();
});
document.getElementById('event-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(eventState.records, null, 2)], {type:'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'memby-events-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
});
document.getElementById('event-level').addEventListener('change', renderEvents);
document.getElementById('event-search').addEventListener('input', renderEvents);
refresh();
setInterval(refresh, 5000);
pollEvents();
setInterval(pollEvents, 5 * 60 * 1000);
</script>
</body>
</html>
+75
View File
@@ -119,6 +119,41 @@ func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
}
}
func TestServiceStatusMakesProtocolMismatchVisible(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Version", "0.9.1")
req.Header.Set("X-Memby-Protocol", "99")
rec := httptest.NewRecorder()
server.handleServiceStatus(rec, req, store.Session{})
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["compatible"] != false || body["compatibilityMessage"] == "" {
t.Fatalf("mismatch was not explicit: %v", body)
}
if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(membyProtocolVersion) {
t.Fatalf("version diagnostics missing: %v", body)
}
}
func TestServiceStatusAcceptsCurrentProtocol(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Protocol", "1")
rec := httptest.NewRecorder()
server.handleServiceStatus(rec, req, store.Session{})
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["compatible"] != true || body["compatibilityMessage"] != "" {
t.Fatalf("current protocol should be compatible: %v", body)
}
}
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
server := testServer(config.Config{})
@@ -163,6 +198,46 @@ func TestAdminAuthRejectsAWrongToken(t *testing.T) {
}
}
func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
req := httptest.NewRequest(http.MethodGet, "https://memby.local/admin/", nil)
rec := httptest.NewRecorder()
server.handleAdminPage(rec, req)
result := rec.Result()
cookies := result.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected one admin cookie, got %d", len(cookies))
}
cookie := cookies[0]
if cookie.Name != adminCookieName || cookie.Value != "secret" {
t.Fatalf("unexpected admin cookie: %#v", cookie)
}
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("admin cookie is not hardened: %#v", cookie)
}
if cookie.MaxAge <= 0 || cookie.Path != "/admin" {
t.Fatalf("admin cookie is not persistent or scoped: %#v", cookie)
}
}
func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: "secret"})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("the admin cookie should be accepted, got %d", rec.Code)
}
}
func TestToRowEventValidatesAndClamps(t *testing.T) {
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
+105 -23
View File
@@ -24,6 +24,8 @@ import (
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/foryou"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -35,9 +37,11 @@ type Server struct {
store *store.Store
cache *cache.Cache
recommender *recommend.Engine
forYou *foryou.Service
sonarr *sonarr.Client
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
sonarrMu sync.Mutex
recommendationBuilds recommendationBuilds
@@ -52,9 +56,11 @@ type Deps struct {
Store *store.Store
Cache *cache.Cache
Recommender *recommend.Engine
ForYou *foryou.Service
Sonarr *sonarr.Client
Syncer syncerHandle
Log *slog.Logger
Events *serverlogging.Buffer
}
func New(cfg config.Config, deps Deps) *Server {
@@ -67,9 +73,11 @@ func New(cfg config.Config, deps Deps) *Server {
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
forYou: deps.ForYou,
sonarr: deps.Sonarr,
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
}
}
@@ -86,13 +94,19 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/home", s.authed(s.handleHome))
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("GET /v1/for-you", s.authed(s.handleForYou))
v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll))
v1.Handle("GET /v1/update", s.authed(s.handleUpdate))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
@@ -138,15 +152,59 @@ func (s *Server) authed(h authedFunc) http.Handler {
writeError(w, http.StatusInternalServerError, "session lookup failed")
return
}
sess = s.captureClientIdentity(r, sess)
h(w, r, sess)
})
}
// captureClientIdentity makes the session the durable source of attribution. Normal API
// calls refresh it from headers; authenticated artwork requests, which can only carry a
// query token, inherit the last identity reported by that same TV.
func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) store.Session {
changed := mergeClientIdentity(r, &sess)
if changed {
if err := s.store.UpdateSessionClientIdentity(
r.Context(), sess.TokenHash, sess.ClientVersion, sess.ClientProtocol,
); err != nil {
s.log.Warn("client identity update failed", "error", err)
} else {
s.cacheSession(r.Context(), sess)
}
}
return sess
}
func mergeClientIdentity(r *http.Request, sess *store.Session) bool {
version := clientVersion(r)
protocol := clientProtocol(r)
changed := false
if version != "" && version != sess.ClientVersion {
sess.ClientVersion = version
changed = true
}
if protocol != "" && protocol != sess.ClientProtocol {
sess.ClientProtocol = protocol
changed = true
}
if version == "" && sess.ClientVersion != "" {
r.Header.Set("X-Memby-Version", sess.ClientVersion)
}
if protocol == "" && sess.ClientProtocol != "" {
r.Header.Set("X-Memby-Protocol", sess.ClientProtocol)
}
return changed
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Polling the live-log endpoint must not create another live-log record and
// become a self-sustaining stream.
if r.URL.Path == "/admin/api/events" {
return
}
// Path only: query strings can carry image tokens.
level := requestLogLevel(r.URL.Path, rec.status)
s.log.Log(r.Context(), level, "HTTP request",
@@ -154,10 +212,19 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start).Round(time.Millisecond),
"client_version", clientLogValue(clientVersion(r)),
"client_protocol", clientLogValue(clientProtocol(r)),
)
})
}
func clientLogValue(value string) string {
if value == "" {
return "unknown"
}
return value
}
// Successful high-frequency probes and artwork fetches stay available at DEBUG without
// overwhelming the normal Docker log. Failures are always promoted so they remain
// visible regardless of path.
@@ -211,12 +278,14 @@ func newToken() (string, error) {
}
type cachedSession struct {
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
ClientVersion string `json:"v,omitempty"`
ClientProtocol string `json:"p,omitempty"`
}
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
@@ -228,13 +297,15 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
var cs cachedSession
if json.Unmarshal(raw, &cs) == nil {
return store.Session{
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
ClientVersion: cs.ClientVersion,
ClientProtocol: cs.ClientProtocol,
}, nil
}
}
@@ -248,16 +319,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
return store.Session{}, store.ErrNotFound
}
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
}); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
}
s.cacheSession(ctx, sess)
// Best-effort activity stamp; a failure here must not fail the request.
if err := s.store.Touch(ctx, hash); err != nil {
s.log.Warn("touch session failed", "error", err)
@@ -265,6 +327,26 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
return sess, nil
}
func (s *Server) cacheSession(ctx context.Context, sess store.Session) {
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion,
ClientProtocol: sess.ClientProtocol,
}); err == nil {
_ = s.cache.Set(
ctx,
cache.SessionKey(hex.EncodeToString(sess.TokenHash)),
raw,
s.cfg.SessionTTL,
)
}
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
+119 -1
View File
@@ -5,9 +5,11 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestBearerTokenSources(t *testing.T) {
@@ -116,6 +118,55 @@ func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) {
}
}
func TestEpisodeAfterUsesPositionNotListLength(t *testing.T) {
episode := func(id string) json.RawMessage {
return json.RawMessage(`{"Id":"` + id + `","Name":"Episode ` + id + `","SeriesName":"Westworld"}`)
}
t.Run("middle of a season", func(t *testing.T) {
items := []json.RawMessage{episode("1"), episode("2"), episode("3")}
raw, next, ok := episodeAfter(items, "2")
if !ok {
t.Fatal("expected an episode after 2")
}
if next.ID != "3" {
t.Fatalf("next = %q, want 3", next.ID)
}
if got := seriesNameOf(raw); got != "Westworld" {
t.Fatalf("series name = %q, want Westworld", got)
}
})
// Emby drops the leading entry for the first episode, so a two-item list can mean
// either "first, second" or "second-to-last, last" depending on where we are.
t.Run("first episode has no previous", func(t *testing.T) {
items := []json.RawMessage{episode("1"), episode("2")}
if _, next, ok := episodeAfter(items, "1"); !ok || next.ID != "2" {
t.Fatalf("next = %+v, ok = %v, want episode 2", next, ok)
}
})
t.Run("series finale has nothing after it", func(t *testing.T) {
items := []json.RawMessage{episode("1"), episode("2")}
if _, _, ok := episodeAfter(items, "2"); ok {
t.Fatal("the last episode must not resolve a next one")
}
})
t.Run("current episode missing from the result", func(t *testing.T) {
items := []json.RawMessage{episode("7"), episode("8")}
if _, _, ok := episodeAfter(items, "42"); ok {
t.Fatal("an unrelated result must not resolve a next episode")
}
})
t.Run("empty result", func(t *testing.T) {
if _, _, ok := episodeAfter(nil, "1"); ok {
t.Fatal("no episodes must not resolve a next one")
}
})
}
// Empty rows must serialise as [] so kotlinx.serialization can decode them into the
// client's non-null List fields.
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
@@ -137,7 +188,22 @@ func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
}
}
// The four fixed rows must keep their order, ids and kinds: the client maps kinds onto
func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) {
resp := searchHistoryResponse{Queries: []string{}}
body, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := decoded["queries"].([]any); !ok {
t.Fatalf("queries encoded as %T, want array", decoded["queries"])
}
}
// The fixed rows must keep their order, ids and kinds: the client maps kinds onto
// card shapes and uses ids as Compose keys.
func TestBaseRowsShape(t *testing.T) {
rows := baseRows(homeResponse{
@@ -168,6 +234,29 @@ func TestBaseRowsShape(t *testing.T) {
}
}
func TestHomeForYouWindowAt(t *testing.T) {
tests := []struct {
hour int
id string
minutes int
}{
{hour: 4, id: "late-night", minutes: 60},
{hour: 5, id: "morning", minutes: 30},
{hour: 11, id: "morning", minutes: 30},
{hour: 12, id: "afternoon", minutes: 60},
{hour: 16, id: "afternoon", minutes: 60},
{hour: 17, id: "evening", minutes: 120},
{hour: 22, id: "evening", minutes: 120},
{hour: 23, id: "late-night", minutes: 60},
}
for _, test := range tests {
got := homeForYouWindowAt(time.Date(2026, time.July, 29, test.hour, 0, 0, 0, time.UTC))
if got.ID != test.id || got.Minutes != test.minutes {
t.Errorf("hour %d: got %#v, want id=%q minutes=%d", test.hour, got, test.id, test.minutes)
}
}
}
func TestRecommendationBuildsAreDeduplicatedPerUser(t *testing.T) {
var builds recommendationBuilds
@@ -200,3 +289,32 @@ func TestSummariseReadsResumePosition(t *testing.T) {
t.Fatalf("resume position = %d ms, want 3600000", got)
}
}
func TestMergeClientIdentityPersistsReportedHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Version", "0.1.60")
req.Header.Set("X-Memby-Protocol", "1")
sess := store.Session{}
if changed := mergeClientIdentity(req, &sess); !changed {
t.Fatal("reported identity should update the session")
}
if sess.ClientVersion != "0.1.60" || sess.ClientProtocol != "1" {
t.Fatalf("session identity = %q/%q", sess.ClientVersion, sess.ClientProtocol)
}
}
func TestMergeClientIdentityAttributesHeaderlessAuthenticatedRequest(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/images/42/primary?t=token", nil)
sess := store.Session{ClientVersion: "0.1.60", ClientProtocol: "1"}
if changed := mergeClientIdentity(req, &sess); changed {
t.Fatal("inheriting identity should not write the session again")
}
if got := clientVersion(req); got != "0.1.60" {
t.Fatalf("inherited client version = %q", got)
}
if got := clientProtocol(req); got != "1" {
t.Fatalf("inherited client protocol = %q", got)
}
}
+13 -7
View File
@@ -86,13 +86,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
sess := store.Session{
TokenHash: hashToken(token),
EmbyUserID: auth.User.ID,
EmbyToken: auth.AccessToken,
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
TokenHash: hashToken(token),
EmbyUserID: auth.User.ID,
EmbyToken: auth.AccessToken,
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
ClientVersion: clientVersion(r),
ClientProtocol: clientProtocol(r),
}
if sess.Username == "" {
sess.Username = req.Username
@@ -132,6 +134,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if len(replacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
+154 -24
View File
@@ -1,12 +1,15 @@
package api
import (
"context"
"encoding/json"
"math/rand/v2"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/cache"
@@ -19,9 +22,8 @@ import (
// single biggest lever on home-screen latency, so keep these tight.
const (
fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
fieldsNextUp = "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
fieldsRow = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
fieldsDetail = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio"
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
@@ -34,7 +36,7 @@ type homeResponse struct {
// without touching the TV app. The client renders whatever arrives.
Rows []recommend.Row `json:"rows"`
// The four fixed rows are also sent flat. They are what the client caches for an
// The fixed rows are also sent flat. They are what the client caches for an
// instant cold start, and what the direct-to-Emby path still produces.
ContinueWatching []json.RawMessage `json:"continueWatching"`
NextUp []json.RawMessage `json:"nextUp"`
@@ -64,11 +66,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
cred := credentials(sess)
var (
mu sync.Mutex
failures int
out homeResponse
sonarrRow *recommend.Row
wg sync.WaitGroup
mu sync.Mutex
failures int
out homeResponse
sonarrRow *recommend.Row
forYouRow *recommend.Row
forYouRowStale bool
wg sync.WaitGroup
)
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
@@ -88,20 +92,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
}
run(&out.ContinueWatching, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"Filters": {"IsResumable"},
"IncludeItemTypes": {"Movie,Episode"},
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {itoa(limit)},
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
"Recursive": {"true"},
"MediaTypes": {"Video"},
"Limit": {itoa(limit)},
}, fieldsContinue))
})
run(&out.NextUp, func() (*emby.ItemsResult, error) {
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsNextUp))
})
run(&out.Favorites, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"Filters": {"IsFavorite"},
@@ -112,6 +108,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
"Limit": {itoa(limit)},
}, fieldsRow))
})
run(&out.NextUp, func() (*emby.ItemsResult, error) {
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsContinue))
})
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"IncludeItemTypes": {"Movie"},
@@ -135,6 +136,48 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
mu.Unlock()
}()
}
if s.forYou != nil {
// The prepared pool is one indexed PostgreSQL read. It runs beside the Emby
// calls and deliberately has no live-engine fallback, so Home can never inherit
// Tracearr fan-out or recommendation rebuild latency.
wg.Add(1)
go func() {
defer wg.Done()
location := s.cfg.SonarrLocation
if location == nil {
location = time.UTC
}
window := homeForYouWindowAt(time.Now().In(location))
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
if err != nil {
s.log.Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
return
}
if !hit || len(prepared) == 0 {
return
}
rowIndex := -1
for i := range prepared {
if prepared[i].ID == "for-you:picks" {
rowIndex = i
break
}
}
if rowIndex < 0 {
return
}
row := prepared[rowIndex]
row.ID = "for-you:home:" + window.ID
row.Title = window.Title
if len(row.Items) > 12 {
row.Items = row.Items[:12]
}
mu.Lock()
forYouRow = &row
forYouRowStale = stale
mu.Unlock()
}()
}
wg.Wait()
@@ -153,9 +196,21 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
s.refreshRecommendationsInBackground(sess)
}
rows := baseRows(out)
nearContinue := make([]recommend.Row, 0, 2)
if forYouRow != nil {
nearContinue = append(nearContinue, *forYouRow)
if forYouRowStale {
s.forYou.MarkDirty(context.WithoutCancel(ctx), sess)
s.forYou.RefreshAsync(sess, false)
}
}
if sonarrRow != nil {
// The schedule is most useful beside Next Up, before personal collections.
rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...)
nearContinue = append(nearContinue, *sonarrRow)
}
if len(nearContinue) > 0 {
// Personalised discovery and today's schedule are most useful immediately
// after Continue Watching, before the broader library collections.
rows = append(rows[:1], append(nearContinue, rows[1:]...)...)
}
out.Rows = append(rows, recommendations...)
@@ -273,7 +328,60 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
writeRaw(w, http.StatusOK, body)
}
// baseRows describes the four fixed rows.
type searchHistoryRequest struct {
Query string `json:"query"`
}
type searchHistoryResponse struct {
Queries []string `json:"queries"`
}
const (
recentSearchDays = 30
recentSearchLimit = 10
)
func (s *Server) handleRecentSearches(w http.ResponseWriter, r *http.Request, sess store.Session) {
if s.store == nil {
writeError(w, http.StatusInternalServerError, "could not load recent searches")
return
}
since := time.Now().Add(-recentSearchDays * 24 * time.Hour)
queries, err := s.store.RecentSearches(
r.Context(),
sess.EmbyUserID,
since,
recentSearchLimit,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load recent searches")
return
}
if queries == nil {
queries = []string{}
}
writeJSON(w, http.StatusOK, searchHistoryResponse{Queries: queries})
}
func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, sess store.Session) {
var req searchHistoryRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid search history request")
return
}
query := strings.TrimSpace(req.Query)
if len([]rune(query)) < 2 || len([]rune(query)) > 200 {
writeError(w, http.StatusBadRequest, "search query length is invalid")
return
}
if s.store == nil || s.store.RecordSearch(r.Context(), sess.EmbyUserID, query) != nil {
writeError(w, http.StatusInternalServerError, "could not record search")
return
}
w.WriteHeader(http.StatusNoContent)
}
// baseRows describes the three fixed rows.
//
// Titles live here rather than in the app so wording can change server-side. They are
// emitted even when empty: the client draws its own "Nothing in progress" message, and a
@@ -283,7 +391,29 @@ func baseRows(h homeResponse) []recommend.Row {
{ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching},
{ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp},
{ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites},
{ID: "latest-movies", Title: "Recently Added Movies", Kind: "latest", Items: h.LatestMovies},
{ID: "latest-movies", Title: "Recent New Releases", Kind: "latest", Items: h.LatestMovies},
}
}
type homeForYouWindow struct {
ID string
Title string
Minutes int
}
// homeForYouWindowAt keeps Home useful without asking the viewer for a duration.
// These deliberately broad windows suit a household TV: short before lunch, an
// episode-sized pick in the afternoon/late evening, and film headroom at night.
func homeForYouWindowAt(now time.Time) homeForYouWindow {
switch hour := now.Hour(); {
case hour >= 5 && hour < 12:
return homeForYouWindow{ID: "morning", Title: "Quick morning picks for you", Minutes: 30}
case hour >= 12 && hour < 17:
return homeForYouWindow{ID: "afternoon", Title: "An hour for your afternoon", Minutes: 60}
case hour >= 17 && hour < 23:
return homeForYouWindow{ID: "evening", Title: "Tonight's picks for you", Minutes: 120}
default:
return homeForYouWindow{ID: "late-night", Title: "Late-night picks for you", Minutes: 60}
}
}
+76 -5
View File
@@ -1,12 +1,16 @@
package api
import (
"context"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"syscall"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -43,6 +47,9 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
params.Set(key, v)
}
}
if writeNotModifiedForTag(w, r, params.Get("tag")) {
return
}
resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params)
if err != nil {
@@ -61,14 +68,14 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
// stay conservative.
if params.Get("tag") != "" {
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("ETag", imageETag(params.Get("tag")))
} else {
w.Header().Set("Cache-Control", "private, max-age=3600")
}
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, resp.Body); err != nil {
s.log.Warn("image copy failed", "error", err)
}
copyImage(w, r, resp.Body, s.log,
"source", "emby", "item_id", itemID, "image_type", imageType)
}
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
@@ -111,7 +118,71 @@ func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemI
}
w.Header().Set("Cache-Control", "private, max-age=3600")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, resp.Body); err != nil {
s.log.Warn("sonarr image copy failed", "error", err)
copyImage(w, r, resp.Body, s.log,
"source", "sonarr", "item_id", itemID, "image_type", imageType)
}
func copyImage(
w io.Writer,
r *http.Request,
body io.Reader,
log *slog.Logger,
attributes ...any,
) {
written, err := io.Copy(w, body)
if err == nil {
return
}
if expectedClientDisconnect(r, err) {
// Image loaders cancel work aggressively as cards leave the viewport. That is a
// successful resource-saving decision by the TV, not an unhealthy gateway.
return
}
fields := append([]any{"bytes_written", written, "error", err}, attributes...)
log.Warn("image stream interrupted", fields...)
}
func expectedClientDisconnect(r *http.Request, err error) bool {
if r.Context().Err() != nil ||
errors.Is(err, context.Canceled) ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, syscall.EPIPE) ||
errors.Is(err, syscall.ECONNRESET) {
return true
}
message := strings.ToLower(err.Error())
for _, fragment := range []string{
"broken pipe",
"connection reset by peer",
"client disconnected",
"request canceled",
"request cancelled",
"stream closed",
} {
if strings.Contains(message, fragment) {
return true
}
}
return false
}
func writeNotModifiedForTag(w http.ResponseWriter, r *http.Request, tag string) bool {
if tag == "" {
return false
}
etag := imageETag(tag)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
for _, candidate := range strings.Split(r.Header.Get("If-None-Match"), ",") {
if strings.TrimSpace(candidate) == etag || strings.TrimSpace(candidate) == "*" {
w.WriteHeader(http.StatusNotModified)
return true
}
}
return false
}
func imageETag(tag string) string {
// Emby image tags are normally hex, but quote defensively for a valid HTTP entity tag.
return `"` + strings.NewReplacer(`\`, "", `"`, "").Replace(tag) + `"`
}
+57 -1
View File
@@ -3,11 +3,16 @@ package api
import (
"encoding/json"
"net/http"
"net/url"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
type seriesEpisodesResponse struct {
Items []json.RawMessage `json:"items"`
}
type flagRequest struct {
Value bool `json:"value"`
}
@@ -21,7 +26,9 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeError(w, http.StatusBadRequest, "item id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "item:"+itemID)
// Version the entry when the detail contract grows so older cached payloads cannot
// hide newly requested fields such as People.
key := cache.UserKey(sess.EmbyUserID, "item:v2:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
@@ -41,6 +48,51 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeRaw(w, http.StatusOK, item)
}
// handleSeriesEpisodes supplies the complete episode browser in one cached response.
// The TV groups by ParentIndexNumber locally, so changing seasons never reaches Emby.
func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
seriesID := r.PathValue("id")
if seriesID == "" {
writeError(w, http.StatusBadRequest, "series id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "series-episodes:"+seriesID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
result, err := s.emby.Episodes(ctx, credentials(sess), seriesID, url.Values{
"UserId": {sess.EmbyUserID},
"Fields": {"Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"},
"EnableUserData": {"true"},
"EnableImages": {"true"},
"EnableImageTypes": {"Primary,Thumb,Backdrop"},
"ImageTypeLimit": {"1"},
"Limit": {"1000"},
})
if err != nil {
s.writeUpstreamError(w, err, "could not load series episodes")
return
}
items := result.Items
if items == nil {
items = []json.RawMessage{}
}
body, err := json.Marshal(seriesEpisodesResponse{Items: items})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not encode series episodes")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.log.Warn("series episodes cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
// handleTrailer answers with the item's first local trailer, or 404 when it has none.
// The screensaver's Play action uses this before asking for a playback URL.
func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -100,5 +152,9 @@ func (s *Server) setFlag(
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("cache invalidation failed", "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, true)
}
writeRaw(w, http.StatusOK, userData)
}
+20 -3
View File
@@ -89,14 +89,31 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler {
// handleServiceStatus is the live control channel clients poll while the app is open.
// It sits outside maintenanceGate so maintenance can interrupt playback rather than only
// being discovered the next time a content request happens to run.
func (s *Server) handleServiceStatus(w http.ResponseWriter, _ *http.Request, _ store.Session) {
// It also carries informational alerts, because this poll is the one thing the app is
// already listening to — a push channel would be a second connection for a banner.
func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ store.Session) {
state := s.maintenance.get()
message := state.Message
if state.Enabled && message == "" {
message = store.DefaultMaintenanceMessage
}
alerts := []clientAlert{}
// Nothing to celebrate while the service is down, and the client is showing the
// maintenance screen anyway.
if !state.Enabled {
if found := s.sonarrAiredAlerts(r.Context()); len(found) > 0 {
alerts = found
}
}
compatible, compatibilityMessage := compatibilityFor(r)
writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled,
"message": message,
"maintenance": state.Enabled,
"message": message,
"alerts": alerts,
"compatible": compatible,
"compatibilityMessage": compatibilityMessage,
"clientVersion": clientVersion(r),
"clientProtocol": clientProtocol(r),
"serverProtocol": membyProtocolVersion,
})
}
+288 -10
View File
@@ -15,16 +15,51 @@ import (
const ticksPerMillisecond = 10_000
type playbackResponse struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
ItemID string `json:"itemId"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
}
type playableSubtitle struct {
ID string `json:"id"`
URL string `json:"url"`
MimeType string `json:"mimeType"`
Language string `json:"language,omitempty"`
Label string `json:"label,omitempty"`
IsDefault bool `json:"isDefault"`
IsForced bool `json:"isForced"`
IsHearingImpaired bool `json:"isHearingImpaired"`
DeliveryMethod string `json:"deliveryMethod"`
Codec string `json:"codec,omitempty"`
}
type playbackReport struct {
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
IsPaused bool `json:"isPaused"`
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
IsPaused bool `json:"isPaused"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
EventName string `json:"eventName,omitempty"`
}
// nextEpisodeResponse carries the episode that follows the one being watched. Item is
// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so
// the client decodes it into the same BaseItem it uses everywhere else.
type nextEpisodeResponse struct {
Item json.RawMessage `json:"item"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
}
// handlePlayback resolves what to actually play.
@@ -74,11 +109,28 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
}
var subtitleIndex *int
if raw := strings.TrimSpace(r.URL.Query().Get("subtitleIndex")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 {
subtitleIndex = &parsed
}
}
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
)
streamURL := s.emby.StreamURL(cred, target.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID,
Title: title,
URL: s.emby.StreamURL(cred, target.ID),
URL: streamURL,
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
})
}
@@ -139,6 +191,225 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
return &summary, nil
}
// handleNextEpisode resolves the episode that follows the one being watched, so the player
// can offer a "next up" countdown without the TV needing to know how Emby orders a series.
//
// "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an
// unreadable series all come back as 404 and the client simply shows no banner.
func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
cred := credentials(sess)
// The client already knows which series it launched, so accepting it as a hint keeps
// this off Emby for one round trip. Older clients omit it and we look it up.
seriesID := strings.TrimSpace(r.URL.Query().Get("seriesId"))
if seriesID == "" {
raw, err := s.emby.Item(ctx, cred, itemID, "SeriesId")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
var parsed struct {
SeriesID string `json:"SeriesId"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
writeError(w, http.StatusBadGateway, "unreadable item from emby")
return
}
seriesID = parsed.SeriesID
}
if seriesID == "" {
writeError(w, http.StatusNotFound, "this item is not part of a series")
return
}
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
"AdjacentTo": {itemID},
"Fields": {"RunTimeTicks,Overview,SeriesName"},
"EnableUserData": {"true"},
"EnableImageTypes": {"Primary,Thumb"},
})
if err != nil {
s.writeUpstreamError(w, err, "could not load the next episode")
return
}
raw, next, ok := episodeAfter(episodes.Items, itemID)
if !ok {
writeError(w, http.StatusNotFound, "no episode follows this one")
return
}
title := next.Name
if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" {
title = series + " " + title
}
subtitles, mediaSourceID, playSessionID, _, playMethod := s.playbackSubtitles(
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "",
)
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
URL: s.emby.StreamURL(cred, next.ID),
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
})
}
func (s *Server) playbackSubtitles(
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
subtitleIndex *int, currentPlaySessionID string,
) ([]playableSubtitle, string, string, string, string) {
info, err := s.emby.PlaybackInfo(
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID,
)
if err != nil {
s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
}
if len(info.MediaSources) == 0 {
return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay"
}
out := make([]playableSubtitle, 0)
seen := make(map[string]bool)
source := info.MediaSources[0]
for _, stream := range source.MediaStreams {
if !strings.EqualFold(stream.Type, "Subtitle") || stream.Index < 0 {
continue
}
method := stream.DeliveryMethod
if method == "" {
if stream.IsTextSubtitleStream {
method = "External"
} else {
method = "Encode"
}
}
delivery := ""
mimeType := subtitleMIME(stream.Codec, stream.DeliveryURL)
if strings.EqualFold(method, "External") && mimeType != "" {
if stream.DeliveryURL != "" {
delivery = s.emby.DeliveryURL(cred, stream.DeliveryURL)
} else {
delivery = s.emby.SubtitleURL(cred, itemID, source.ID, stream.Index, subtitleExtension(stream.Codec))
}
}
key := strconv.Itoa(stream.Index)
if seen[key] {
continue
}
seen[key] = true
label := strings.TrimSpace(stream.DisplayTitle)
if label == "" {
label = strings.TrimSpace(stream.Title)
}
out = append(out, playableSubtitle{
ID: strconv.Itoa(stream.Index),
URL: delivery,
MimeType: mimeType,
Language: strings.TrimSpace(stream.Language),
Label: label,
IsDefault: stream.IsDefault,
IsForced: stream.IsForced,
IsHearingImpaired: stream.IsHearingImpaired ||
strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "sdh") ||
strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "hearing"),
DeliveryMethod: method,
Codec: stream.Codec,
})
}
negotiatedURL := ""
playMethod := "DirectPlay"
if subtitleIndex != nil && source.TranscodingURL != "" {
negotiatedURL = s.emby.DeliveryURL(cred, source.TranscodingURL)
playMethod = "Transcode"
}
return out, source.ID, info.PlaySessionID, negotiatedURL, playMethod
}
func subtitleExtension(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "subrip":
return "srt"
case "webvtt":
return "vtt"
case "tx3g":
return "mov_text"
default:
if strings.TrimSpace(codec) == "" {
return "vtt"
}
return strings.ToLower(strings.TrimSpace(codec))
}
}
func subtitleMIME(codec, delivery string) string {
value := strings.ToLower(strings.TrimSpace(codec))
if value == "" {
path := delivery
if parsed, err := url.Parse(delivery); err == nil {
path = parsed.Path
}
if dot := strings.LastIndex(path, "."); dot >= 0 {
value = strings.ToLower(path[dot+1:])
}
}
switch value {
case "srt", "subrip":
return "application/x-subrip"
case "vtt", "webvtt":
return "text/vtt"
case "ass", "ssa":
return "text/x-ssa"
case "ttml", "dfxp":
return "application/ttml+xml"
case "tx3g", "mov_text":
return "application/x-quicktime-tx3g"
default:
return ""
}
}
// episodeAfter picks the episode following currentID out of an AdjacentTo result, which
// Emby returns in running order as [previous, current, next] minus whichever ends do not
// exist — so the position of the current episode is what identifies the next one, not the
// length of the list.
func episodeAfter(items []json.RawMessage, currentID string) (json.RawMessage, emby.Summary, bool) {
for i, raw := range items {
summary, err := emby.Summarise(raw)
if err != nil || summary.ID != currentID {
continue
}
if i+1 >= len(items) {
return nil, emby.Summary{}, false
}
next, err := emby.Summarise(items[i+1])
if err != nil || next.ID == "" {
return nil, emby.Summary{}, false
}
return items[i+1], next, true
}
return nil, emby.Summary{}, false
}
func seriesNameOf(raw json.RawMessage) string {
var parsed struct {
SeriesName string `json:"SeriesName"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return ""
}
return parsed.SeriesName
}
// handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache
// so Continue Watching reflects the new position on the next home load.
func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -160,8 +431,11 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
return
}
err := s.emby.ReportPlayback(r.Context(), credentials(sess), phase, report.ItemID,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused)
err := s.emby.ReportPlayback(
r.Context(), credentials(sess), phase, report.ItemID, report.MediaSourceID,
report.PlaySessionID, report.PlayMethod, report.EventName,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
)
if err != nil {
// A dropped progress report is not worth failing playback over; log and accept.
s.log.Warn("playback report failed", "phase", phase, "error", err)
@@ -176,6 +450,10 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("recommendation invalidation failed", "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, true)
}
}
w.WriteHeader(http.StatusNoContent)
}
+40
View File
@@ -124,6 +124,46 @@ func (s *Server) handleRecommendations(w http.ResponseWriter, r *http.Request, s
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
}
// handleForYou is deliberately separate from /home. Tracearr and the richer scoring
// path may take longer than a launcher request, and the chosen time budget is local to
// this visit. The TV only calls this when the viewer enters the dedicated area.
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store.Session) {
minutes := queryInt(r, "minutes", 0, 360)
if s.forYou != nil && r.URL.Query().Get("refresh") != "1" {
rows, hit, stale, err := s.forYou.PreparedRows(r.Context(), sess, minutes)
if err != nil {
s.log.Warn("prepared For You read failed; using live fallback",
"user", sess.EmbyUserID, "error", err)
} else if hit {
w.Header().Set("X-Memby-For-You", "prepared")
if stale {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
return
} else {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
}
buildCtx, cancel := context.WithTimeout(r.Context(), s.cfg.RecommendTimeout)
defer cancel()
rows, err := s.recommender.BuildForYou(
buildCtx,
credentials(sess),
sess.Username,
recommend.ForYouOptions{AvailableMinutes: minutes},
)
if err != nil {
s.writeUpstreamError(w, err, "could not build For You recommendations")
return
}
w.Header().Set("X-Memby-For-You", "live-fallback")
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
}
func nonNilRows(rows []recommend.Row) []recommend.Row {
if rows == nil {
return []recommend.Row{}
+22
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -11,6 +12,11 @@ import (
"github.com/ponzischeme89/memby/server/internal/store"
)
// membyProtocolVersion changes only when the client/server wire contract is no longer
// mutually compatible. App release versions remain independent and are handled by the
// update policy.
const membyProtocolVersion = 1
// updatePolicyCache keeps the policy in memory. It is read on every home request, and a
// database round trip per home load to answer "nothing to say" would be wasteful.
type updatePolicyCache struct {
@@ -63,6 +69,22 @@ func clientVersion(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Memby-Version"))
}
func clientProtocol(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Memby-Protocol"))
}
func compatibilityFor(r *http.Request) (bool, string) {
reported, err := strconv.Atoi(clientProtocol(r))
if err != nil || reported != membyProtocolVersion {
if clientProtocol(r) == "" {
return false, "This Memby app is too old to verify compatibility with the server. Update the app."
}
return false, "Memby app/server mismatch: app protocol " + clientProtocol(r) +
", server protocol " + strconv.Itoa(membyProtocolVersion) + ". Update the app or server."
}
return true, ""
}
// handleUpdate answers the client's version check.
//
// Its own endpoint rather than a field on /v1/home: the home payload is cached per user,
+1 -1
View File
@@ -87,7 +87,7 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
// Recommendations cost several Emby queries to build, so they must survive the cache
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v2", userID) }
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v3", userID) }
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
return c.Delete(ctx, RecommendationsKey(userID))
+41 -10
View File
@@ -78,6 +78,26 @@ type Config struct {
SonarrAPIKey string
SonarrTTL time.Duration
SonarrLocation *time.Location
// SonarrAlertWindow is how long after an episode airs the "aired, coming soon"
// banner keeps being offered to clients. Zero turns the banners off without
// touching the airing-today row.
SonarrAlertWindow time.Duration
// Tracearr is an optional, read-only source of completion, session-length and
// direct-play signals for the per-user For You area. The public API key stays in
// the gateway and is never returned to a TV.
TracearrURL string
TracearrAPIKey string
TracearrServerID string
// TracearrSyncInterval imports recent changed sessions. FullInterval reconciles
// late/out-of-order updates and deletions without needing a source cursor.
TracearrSyncInterval time.Duration
TracearrFullInterval time.Duration
// ForYouMinRebuildAge coalesces bursts of playback/library changes. RefreshInterval
// is the acceptable age of a prepared pool before it is refreshed.
ForYouMinRebuildAge time.Duration
ForYouRefreshInterval time.Duration
}
func Load() (Config, error) {
@@ -104,16 +124,24 @@ func Load() (Config, error) {
ReleasePublishToken: strings.TrimSpace(
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
),
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute),
TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour),
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 10*time.Minute),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 30*time.Minute),
}
if c.EmbyURL == "" {
@@ -134,6 +162,9 @@ func Load() (Config, error) {
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
}
if (c.TracearrURL == "") != (c.TracearrAPIKey == "") {
return c, fmt.Errorf("MEMBY_TRACEARR_URL and MEMBY_TRACEARR_API_KEY must be set together")
}
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
if err != nil {
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
+200 -2
View File
@@ -47,6 +47,14 @@ type AuthResult struct {
ServerID string `json:"ServerId"`
}
type User struct {
ID string `json:"Id"`
Name string `json:"Name"`
Policy struct {
IsDisabled bool `json:"IsDisabled"`
} `json:"Policy"`
}
// Summary is the minimal view of an item the gateway needs for its own logic.
type Summary struct {
ID string `json:"Id"`
@@ -57,6 +65,35 @@ type Summary struct {
} `json:"UserData"`
}
type PlaybackInfo struct {
MediaSources []MediaSourceInfo `json:"MediaSources"`
PlaySessionID string `json:"PlaySessionId"`
}
type MediaSourceInfo struct {
ID string `json:"Id"`
MediaStreams []MediaStream `json:"MediaStreams"`
DirectStreamURL string `json:"DirectStreamUrl"`
TranscodingURL string `json:"TranscodingUrl"`
}
type MediaStream struct {
Index int `json:"Index"`
Type string `json:"Type"`
Codec string `json:"Codec"`
Title string `json:"Title"`
DisplayTitle string `json:"DisplayTitle"`
Language string `json:"Language"`
IsDefault bool `json:"IsDefault"`
IsForced bool `json:"IsForced"`
IsHearingImpaired bool `json:"IsHearingImpaired"`
IsExternal bool `json:"IsExternal"`
IsTextSubtitleStream bool `json:"IsTextSubtitleStream"`
SupportsExternalStream bool `json:"SupportsExternalStream"`
DeliveryURL string `json:"DeliveryUrl"`
DeliveryMethod string `json:"DeliveryMethod"`
}
// APIError carries an upstream Emby status code so handlers can mirror it.
type APIError struct {
StatusCode int
@@ -115,6 +152,20 @@ func (c *Client) Logout(ctx context.Context, cred Credentials) error {
return c.do(req, nil)
}
// Users returns the household accounts visible to an administrative/service token.
// It is used only by the background For You builder, never on a television request.
func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/Users", nil, cred, nil)
if err != nil {
return nil, err
}
var users []User
if err := c.do(req, &users); err != nil {
return nil, err
}
return users, nil
}
func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params)
}
@@ -167,6 +218,93 @@ func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields stri
return raw, nil
}
func (c *Client) PlaybackInfo(
ctx context.Context,
cred Credentials,
itemID string,
startTicks int64,
subtitleStreamIndex *int,
currentPlaySessionID string,
) (*PlaybackInfo, error) {
params := url.Values{
"UserId": {cred.UserID},
"IsPlayback": {"true"},
}
body, err := json.Marshal(map[string]any{
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
"StartTimeTicks": startTicks,
"DeviceProfile": map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
"VideoCodec": "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
"AudioCodec": "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
"Type": "Video",
},
},
"TranscodingProfiles": []map[string]string{
{
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
},
"SubtitleProfiles": []map[string]string{
{"Format": "srt", "Method": "External"},
{"Format": "subrip", "Method": "External"},
{"Format": "ass", "Method": "External"},
{"Format": "ssa", "Method": "External"},
{"Format": "vtt", "Method": "External"},
{"Format": "webvtt", "Method": "External"},
{"Format": "mov_text", "Method": "External"},
{"Format": "tx3g", "Method": "External"},
{"Format": "pgs", "Method": "Encode"},
{"Format": "pgssub", "Method": "Encode"},
{"Format": "sup", "Method": "Encode"},
{"Format": "vobsub", "Method": "Encode"},
{"Format": "dvdsub", "Method": "Encode"},
},
},
})
var requestBody map[string]any
if err == nil {
err = json.Unmarshal(body, &requestBody)
}
if subtitleStreamIndex != nil {
requestBody["SubtitleStreamIndex"] = *subtitleStreamIndex
}
if currentPlaySessionID != "" {
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
}
if err == nil {
body, err = json.Marshal(requestBody)
}
if err != nil {
return nil, err
}
req, err := c.newRequest(
ctx,
http.MethodPost,
"/Items/"+url.PathEscape(itemID)+"/PlaybackInfo",
params,
cred,
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
var out PlaybackInfo
if err := c.do(req, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params)
}
// SetFavorite/SetPlayed return Emby's resulting UserData verbatim.
func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) {
method := http.MethodDelete
@@ -187,7 +325,13 @@ func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string,
}
// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped".
func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase string, itemID string, positionTicks int64, isPaused bool) error {
func (c *Client) ReportPlayback(
ctx context.Context,
cred Credentials,
phase, itemID, mediaSourceID, playSessionID, playMethod, eventName string,
positionTicks int64,
isPaused bool,
) error {
var path string
switch phase {
case "started":
@@ -202,12 +346,22 @@ func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase str
body, err := json.Marshal(map[string]any{
"ItemId": itemID,
"MediaSourceId": mediaSourceID,
"PlaySessionId": playSessionID,
"PositionTicks": positionTicks,
"IsPaused": isPaused,
"IsMuted": false,
"CanSeek": true,
"PlayMethod": "DirectPlay",
"PlayMethod": playMethod,
})
if phase == "progress" {
var fields map[string]any
if err := json.Unmarshal(body, &fields); err != nil {
return err
}
fields["EventName"] = eventName
body, err = json.Marshal(fields)
}
if err != nil {
return err
}
@@ -249,6 +403,50 @@ func (c *Client) StreamURL(cred Credentials, itemID string) string {
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
}
// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL.
func (c *Client) DeliveryURL(cred Credentials, delivery string) string {
delivery = strings.TrimSpace(delivery)
if delivery == "" {
return ""
}
var resolved string
if parsed, err := url.Parse(delivery); err == nil && parsed.IsAbs() {
resolved = parsed.String()
} else {
resolved = c.publicURL + "/" + strings.TrimLeft(delivery, "/")
}
parsed, err := url.Parse(resolved)
if err != nil {
return resolved
}
query := parsed.Query()
if query.Get("api_key") == "" {
query.Set("api_key", cred.Token)
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
// SubtitleURL uses Emby's stable subtitle download route rather than the optional
// MediaStream.DeliveryUrl. VTT normalizes every text subtitle codec before it reaches
// the TV.
func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, index int, extensions ...string) string {
if mediaSourceID == "" {
mediaSourceID = itemID
}
extension := "vtt"
if len(extensions) > 0 && strings.TrimSpace(extensions[0]) != "" {
extension = extensions[0]
}
path := fmt.Sprintf(
"/Videos/%s/%s/Subtitles/%d/Stream.%s",
url.PathEscape(itemID),
url.PathEscape(mediaSourceID),
index, url.PathEscape(extension),
)
return c.DeliveryURL(cred, path)
}
// Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error {
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
+23 -2
View File
@@ -56,14 +56,22 @@ type Syncer struct {
// is borrowed instead.
serviceCred emby.Credentials
mu sync.Mutex
running bool
mu sync.Mutex
running bool
afterSync func()
}
func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer {
return &Syncer{emby: embyClient, store: st, serviceCred: serviceCred, log: log}
}
// SetAfterSync installs the inexpensive invalidation callback used by derived data.
func (s *Syncer) SetAfterSync(callback func()) {
s.mu.Lock()
defer s.mu.Unlock()
s.afterSync = callback
}
// Result summarises one import.
type Result struct {
Kind string `json:"kind"`
@@ -150,6 +158,12 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
"kind", kind, "trigger", trigger, "seen", result.Seen,
"upserted", result.Upserted, "removed", result.Removed,
"duration", result.Duration.Round(time.Millisecond))
s.mu.Lock()
afterSync := s.afterSync
s.mu.Unlock()
if afterSync != nil {
afterSync()
}
return result, nil
}
@@ -345,5 +359,12 @@ func searchText(parsed syncItem) string {
parts = append(parts, strconv.Itoa(*parsed.ProductionYear))
}
parts = append(parts, parsed.Genres...)
// Studios too, so "A24" or "Pixar" finds a shelf's worth of titles. Existing rows
// keep their old text until the next full import rewrites them.
for _, studio := range parsed.Studios {
if studio.Name != "" {
parts = append(parts, studio.Name)
}
}
return strings.Join(parts, " ")
}
+17
View File
@@ -70,6 +70,23 @@ func TestSearchTextIncludesSeriesNameSoEpisodesAreFindable(t *testing.T) {
}
}
func TestSearchTextIncludesStudiosSoAStudioNameFindsItsTitles(t *testing.T) {
text := searchText(syncItem{
Name: "Everything Everywhere All at Once",
Type: "Movie",
Studios: []struct {
Name string `json:"Name"`
}{{Name: "A24"}, {Name: ""}},
})
if !strings.Contains(text, "A24") {
t.Fatalf("search text %q is missing the studio", text)
}
if strings.Contains(text, " ") {
t.Fatalf("an unnamed studio should be skipped, not joined as a gap: %q", text)
}
}
func TestSearchTextDoesNotRepeatTheTitleForAMovie(t *testing.T) {
text := searchText(syncItem{Name: "Dune", Type: "Movie", SeriesName: "Dune"})
+500 -19
View File
@@ -4,13 +4,16 @@ import (
"context"
"encoding/json"
"log/slog"
"math"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
// Row is one horizontal strip on the TV home screen.
@@ -28,6 +31,13 @@ type Source interface {
Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error)
}
// NextUpSource is optional because the on-demand engine and small test sources do not
// need it. The production Emby client implements it; prepared rebuilds use one Next Up
// request to prove that an abandoned programme still has an unwatched episode.
type NextUpSource interface {
NextUp(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
}
// LibrarySource is the imported catalogue. When present, the candidate pool comes from
// Postgres instead of Emby, which takes the rebuild off Emby entirely.
type LibrarySource interface {
@@ -44,15 +54,29 @@ type CuratedLibrarySource interface {
) ([]json.RawMessage, error)
}
type TracearrSource interface {
History(ctx context.Context, username string, limit int) ([]tracearr.Session, error)
}
type BehaviorSource interface {
BrowsingCandidates(
ctx context.Context,
userID string,
since time.Time,
limit int,
) ([]json.RawMessage, error)
}
// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
// the user's profile determines both item order and shelf order.
type CuratedRow struct {
ID string
Title string
Kind string
ItemTypes []string
Genres []string
Studios []string
ID string
Title string
Kind string
ItemTypes []string
Genres []string
Studios []string
RequireAffinity bool
}
type Engine struct {
@@ -60,7 +84,9 @@ type Engine struct {
log *slog.Logger
// Library is optional; nil (or an empty library) falls back to querying Emby.
Library LibrarySource
Library LibrarySource
Tracearr TracearrSource
Behavior BehaviorSource
// MinRowItems is the shortest row worth showing. A two-item "Recommended" strip
// looks broken next to full rows, so short rows are dropped entirely.
@@ -72,6 +98,333 @@ type Engine struct {
CuratedRows []CuratedRow
}
// ForYouOptions are request-scoped constraints chosen on the television.
type ForYouOptions struct {
// AvailableMinutes is zero for no time limit.
AvailableMinutes int
}
type compatibilityProfile struct {
directCodecs map[string]int
transcodeCodecs map[string]int
}
// BuildForYou creates the dedicated, explainable recommendation area. Emby supplies
// catalogue metadata, Tracearr supplies completion and real device/playback outcomes,
// and Memby's own row analytics supplies browsing intent.
func (e *Engine) BuildForYou(
ctx context.Context,
cred emby.Credentials,
username string,
options ForYouOptions,
) ([]Row, error) {
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return nil, err
}
profile := BuildProfile(history, favorites)
var sessions []tracearr.Session
if e.Tracearr != nil {
if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr)
} else {
sessions = recommendationSessions(fetched)
e.applyTracearrSignals(&profile, history, sessions)
}
}
browsed := map[string]bool{}
if e.Behavior != nil {
raws, browseErr := e.Behavior.BrowsingCandidates(
ctx,
cred.UserID,
time.Now().Add(-30*24*time.Hour),
30,
)
if browseErr != nil {
e.log.Warn("browsing signals unavailable", "error", browseErr)
} else {
for i, item := range Decode(raws) {
weight := 0.55 * powDecay(0.92, i)
profile.absorbTaste(item, weight)
browsed[item.ID] = true
}
}
}
genres := profile.TopGenres(4)
if len(genres) == 0 {
return []Row{}, nil
}
candidates, ok := e.libraryCandidatesForYou(ctx, cred, genres)
if !ok {
return []Row{}, nil
}
compatibility := buildCompatibilityProfile(sessions)
items := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize)
if len(items) < e.MinRowItems {
return []Row{}, nil
}
raws := make([]json.RawMessage, 0, len(items))
for _, item := range items {
reason, compatibilityLabel := explainRecommendation(
profile,
item,
options.AvailableMinutes,
compatibility,
browsed[item.ID],
)
raws = append(raws, enrichRecommendation(item.Raw, reason, compatibilityLabel))
}
title := "Top picks for you"
if options.AvailableMinutes > 0 {
title = "Top picks that fit in " + strconv.Itoa(options.AvailableMinutes) + " minutes"
}
return []Row{{
ID: "for-you:picks",
Title: title,
Kind: "for-you",
Items: raws,
}}, nil
}
func powDecay(base float64, position int) float64 {
return math.Pow(base, float64(position))
}
func (e *Engine) applyTracearrSignals(
profile *Profile,
history []Item,
sessions []tracearr.Session,
) {
byTitle := make(map[string]Item, len(history))
for _, item := range history {
byTitle[item.TitleKey()] = item
}
for i, session := range sessions {
if session.Completion() > 0 {
profile.SeenTitles[tracearrSeenKey(session)] = true
}
item, ok := byTitle[session.TitleKey()]
if !ok {
continue
}
// Completion distinguishes "finished it twice" from "abandoned after ten
// minutes"; recency lets changing tastes move promptly.
weight := (0.2 + session.Completion()) * powDecay(0.985, i)
profile.absorbTaste(item, weight)
}
}
func (e *Engine) libraryCandidatesForYou(
ctx context.Context,
cred emby.Credentials,
genres []string,
) ([]Item, bool) {
if e.Library != nil {
raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*12)
if err == nil && len(raws) > 0 {
return Decode(raws), true
}
if err != nil {
e.log.Warn("for-you library candidates failed; falling back to emby", "error", err)
}
}
result, err := e.source.Items(ctx, cred, url.Values{
"IncludeItemTypes": {"Movie,Series"},
"Recursive": {"true"},
"Filters": {"IsUnplayed"},
"Genres": {strings.Join(genres, "|")},
"SortBy": {"CommunityRating"},
"SortOrder": {"Descending"},
"Limit": {strconv.Itoa(e.RowSize * 8)},
"Fields": {candidateFields + ",MediaStreams,Container"},
"ImageTypeLimit": {"1"},
"EnableImages": {"true"},
"EnableImageTypes": {rowImageTypes},
"EnableUserData": {"true"},
})
if err != nil {
e.log.Warn("for-you emby candidates failed", "error", err)
return nil, false
}
return Decode(result.Items), len(result.Items) > 0
}
func buildCompatibilityProfile(sessions []tracearr.Session) compatibilityProfile {
profile := compatibilityProfile{
directCodecs: map[string]int{},
transcodeCodecs: map[string]int{},
}
for _, session := range sessions {
if !session.IsTelevisionSession() {
continue
}
for _, codec := range []string{session.SourceVideoCodec, session.SourceAudioCodec} {
codec = strings.ToLower(strings.TrimSpace(codec))
if codec == "" {
continue
}
if session.IsTranscode ||
strings.EqualFold(session.VideoDecision, "transcode") ||
strings.EqualFold(session.AudioDecision, "transcode") {
profile.transcodeCodecs[codec]++
} else {
profile.directCodecs[codec]++
}
}
}
return profile
}
func compatibilityScore(item Item, profile compatibilityProfile) float64 {
var score float64
var known int
for _, stream := range item.MediaStreams {
if !strings.EqualFold(stream.Type, "Video") && !strings.EqualFold(stream.Type, "Audio") {
continue
}
codec := strings.ToLower(strings.TrimSpace(stream.Codec))
direct, transcode := profile.directCodecs[codec], profile.transcodeCodecs[codec]
if direct+transcode == 0 {
continue
}
known++
score += float64(direct-transcode) / float64(direct+transcode)
}
if known == 0 {
return 0
}
return score / float64(known)
}
func rankForYou(
profile Profile,
candidates []Item,
availableMinutes int,
compatibility compatibilityProfile,
limit int,
) []Item {
type scored struct {
item Item
score float64
}
ranked := make([]scored, 0, len(candidates))
seen := map[string]bool{}
for _, candidate := range candidates {
if seen[candidate.ID] {
continue
}
seen[candidate.ID] = true
base := profile.Score(candidate)
if base < 0 {
continue
}
runtime := candidate.RuntimeMinutes()
if availableMinutes > 0 && (runtime <= 0 || runtime > availableMinutes) {
continue
}
score := base + compatibilityScore(candidate, compatibility)*1.4
if availableMinutes > 0 {
// Prefer a satisfying fit over something dramatically shorter, without
// allowing runtime to overwhelm taste.
score += float64(runtime) / float64(availableMinutes) * 0.35
}
ranked = append(ranked, scored{item: candidate, score: score})
}
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
})
if limit > 0 && len(ranked) > limit {
ranked = ranked[:limit]
}
out := make([]Item, 0, len(ranked))
for _, entry := range ranked {
out = append(out, entry.item)
}
return out
}
func explainRecommendation(
profile Profile,
item Item,
availableMinutes int,
compatibility compatibilityProfile,
browsed bool,
) (string, string) {
top := profile.TopGenres(5)
matched := ""
for _, wanted := range top {
for _, genre := range item.Genres {
if strings.EqualFold(wanted, genre) {
matched = genre
break
}
}
if matched != "" {
break
}
}
reasons := make([]string, 0, 3)
if browsed {
reasons = append(reasons, "You explored this recently")
} else if matched != "" {
reasons = append(reasons, "Matches your "+matched+" viewing")
} else if len(profile.Seeds) > 0 {
reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name)
} else {
reasons = append(reasons, "Matches your recent viewing")
}
if availableMinutes > 0 && item.RuntimeMinutes() > 0 {
reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window")
}
compatibilityLabel := ""
switch score := compatibilityScore(item, compatibility); {
case score > 0.2:
compatibilityLabel = "Direct plays well on this TV"
reasons = append(reasons, compatibilityLabel)
case score < -0.2:
compatibilityLabel = "May need transcoding on this TV"
default:
compatibilityLabel = "TV compatibility not yet learned"
}
return strings.Join(reasons, " · "), compatibilityLabel
}
func enrichRecommendation(raw json.RawMessage, reason, compatibility string) json.RawMessage {
var item map[string]any
if err := json.Unmarshal(raw, &item); err != nil {
return raw
}
item["MembyRecommendationReason"] = reason
item["MembyCompatibility"] = compatibility
enriched, err := json.Marshal(item)
if err != nil {
return raw
}
return enriched
}
// EnrichPreparedRecommendation adds request-time details to a candidate selected from
// the prepared PostgreSQL pool. The stored reason remains stable; only the viewer's
// current time budget is appended here.
func EnrichPreparedRecommendation(
raw json.RawMessage,
reason, compatibility string,
availableMinutes int,
) json.RawMessage {
if availableMinutes > 0 {
reason += " · fits your " + strconv.Itoa(availableMinutes) + "-minute window"
}
return enrichRecommendation(raw, reason, compatibility)
}
func NewEngine(source Source, log *slog.Logger) *Engine {
return &Engine{
source: source,
@@ -89,22 +442,81 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
},
{
ID: "curated:drama-shows",
Title: "Drama TV Shows",
Title: "Drama Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Drama"},
},
{
ID: "curated:comedy-shows",
Title: "Comedy TV Shows",
Title: "Comedy Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Comedy"},
},
{
ID: "curated:horror-shows",
Title: "Horror Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Horror"},
},
// Movie shelves are deliberately numerous definitions but sparse output:
// only the user's six strongest matching genres and three strongest studio
// families survive buildCuratedRows.
movieGenreRow("action", "Action"),
movieGenreRow("adventure", "Adventure"),
movieGenreRow("animation", "Animation"),
movieGenreRow("comedy", "Comedy"),
movieGenreRow("crime", "Crime"),
movieGenreRow("documentary", "Documentary"),
movieGenreRow("drama", "Drama"),
movieGenreRow("family", "Family"),
movieGenreRow("fantasy", "Fantasy"),
movieGenreRow("horror", "Horror"),
movieGenreRow("mystery", "Mystery"),
movieGenreRow("romance", "Romance"),
movieGenreRow("science-fiction", "Science Fiction"),
movieGenreRow("thriller", "Thriller"),
movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
movieStudioRow(
"disney", "Disney",
"Disney", "Walt Disney Pictures", "Walt Disney Animation Studios",
),
movieStudioRow("marvel", "Marvel Studios", "Marvel Studios"),
movieStudioRow("lucasfilm", "Lucasfilm", "Lucasfilm", "Lucasfilm Ltd."),
movieStudioRow(
"dreamworks", "DreamWorks",
"DreamWorks", "DreamWorks Pictures", "DreamWorks Animation",
),
movieStudioRow(
"warner-bros", "Warner Bros.",
"Warner Bros.", "Warner Bros. Pictures", "Warner Brothers",
),
movieStudioRow(
"universal", "Universal",
"Universal Pictures", "Universal Studios",
),
movieStudioRow("a24", "A24", "A24"),
movieStudioRow("studio-ghibli", "Studio Ghibli", "Studio Ghibli"),
},
}
}
func movieGenreRow(id, genre string) CuratedRow {
return CuratedRow{
ID: "curated:movies:genre:" + id, Title: genre + " Movies", Kind: "movies",
ItemTypes: []string{"Movie"}, Genres: []string{genre}, RequireAffinity: true,
}
}
func movieStudioRow(id, title string, studios ...string) CuratedRow {
return CuratedRow{
ID: "curated:movies:studio:" + id, Title: "More from " + title, Kind: "movies",
ItemTypes: []string{"Movie"}, Studios: studios, RequireAffinity: true,
}
}
const (
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
@@ -152,11 +564,22 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
}
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
for order, definition := range e.CuratedRows {
definitions = append(definitions, rankedDefinition{
affinity := profile.CollectionAffinity(definition.Genres, definition.Studios)
// Studio signals are intentionally damped while scoring individual titles.
// Restore enough weight at shelf level for a genuinely followed studio to earn
// a row before genre shelves consume all of its films during deduplication.
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
affinity *= 3
}
ranked := rankedDefinition{
definition: definition,
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
affinity: affinity,
order: order,
})
}
if definition.RequireAffinity && ranked.affinity <= 0 {
continue
}
definitions = append(definitions, ranked)
}
sort.SliceStable(definitions, func(i, j int) bool {
if definitions[i].affinity != definitions[j].affinity {
@@ -166,8 +589,23 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
})
rows := make([]Row, 0, len(definitions))
// A series may carry several genres. Give it to the user's highest-affinity shelf
// only, so scrolling Shows never reveals the same card again under another label.
seenItems := make(map[string]struct{})
movieGenreRows := 0
movieStudioRows := 0
for _, ranked := range definitions {
definition := ranked.definition
switch {
case strings.HasPrefix(definition.ID, "curated:movies:genre:"):
if movieGenreRows >= 6 {
continue
}
case strings.HasPrefix(definition.ID, "curated:movies:studio:"):
if movieStudioRows >= 3 {
continue
}
}
raws, err := library.CuratedCandidates(
ctx,
definition.ItemTypes,
@@ -179,16 +617,35 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
continue
}
items := RankCollection(profile, Decode(raws), e.RowSize)
rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
items := make([]Item, 0, e.RowSize)
for _, item := range rankedItems {
if _, seen := seenItems[item.ID]; seen {
continue
}
items = append(items, item)
if len(items) == e.RowSize {
break
}
}
if len(items) < e.MinRowItems {
continue
}
for _, item := range items {
seenItems[item.ID] = struct{}{}
}
rows = append(rows, Row{
ID: definition.ID,
Title: definition.Title,
Kind: definition.Kind,
Items: Raws(items),
})
if strings.HasPrefix(definition.ID, "curated:movies:genre:") {
movieGenreRows++
}
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
movieStudioRows++
}
}
return rows
}
@@ -227,18 +684,20 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {"20"},
"Fields": {historyFields},
"EnableUserData": {"true"},
"EnableImages": {"false"},
// Imported catalogue rows have no user state, so this query is also the
// exclusion set. Household libraries are small enough to fetch it completely.
"Limit": {"5000"},
"Fields": {historyFields},
"EnableUserData": {"true"},
"EnableImages": {"false"},
})
fetch(&played, url.Values{
"Filters": {"IsPlayed"},
"IncludeItemTypes": {"Movie,Episode"},
"IncludeItemTypes": {"Movie,Episode,Series"},
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {"60"},
"Limit": {"5000"},
"Fields": {historyFields},
"EnableUserData": {"true"},
"EnableImages": {"false"},
@@ -261,6 +720,28 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
return append(resumable, played...), favorites, nil
}
func recommendationSessions(sessions []tracearr.Session) []tracearr.Session {
out := make([]tracearr.Session, 0, len(sessions))
for _, session := range sessions {
// Prerolls are delivery mechanics, not a viewer choice. Treating dozens of
// completed prerolls as taste evidence overwhelms real household history.
if strings.HasPrefix(session.TitleKey(), "preroll") {
continue
}
out = append(out, session)
}
return out
}
func tracearrSeenKey(session tracearr.Session) string {
key := session.TitleKey()
if key != "" && strings.EqualFold(session.MediaType, "movie") &&
session.Year != nil && *session.Year > 0 {
return key + "|" + strconv.Itoa(*session.Year)
}
return key
}
// libraryCandidates reads the pool from the imported library. Returns ok=false when
// there is no library, it is empty, or it errors — every one of which means "ask Emby".
func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) {
+455 -1
View File
@@ -7,11 +7,14 @@ import (
"io"
"log/slog"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
// fakeSource records the queries the engine makes and replays canned answers.
@@ -22,6 +25,8 @@ type fakeSource struct {
similar map[string][]json.RawMessage
itemsErr error
similarErr error
nextUp []json.RawMessage
nextUpErr error
genreQueries []string
similarSeeds []string
@@ -31,6 +36,36 @@ type fakeCuratedLibrary struct {
byGenre map[string][]json.RawMessage
}
type fakeForYouLibrary struct {
items []json.RawMessage
}
func (f *fakeForYouLibrary) AllRecommendationCandidates(
_ context.Context,
) ([]json.RawMessage, error) {
return f.items, nil
}
func (f *fakeForYouLibrary) LibraryCandidates(
_ context.Context,
_ []string,
_ int,
) ([]json.RawMessage, error) {
return f.items, nil
}
type fakeTracearr struct {
sessions []tracearr.Session
}
func (f fakeTracearr) History(
_ context.Context,
_ string,
_ int,
) ([]tracearr.Session, error) {
return f.sessions, nil
}
func (f *fakeCuratedLibrary) LibraryCandidates(
_ context.Context,
_ []string,
@@ -62,7 +97,11 @@ func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Val
f.genreQueries = append(f.genreQueries, genres)
}
key := params.Get("Filters")
return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil
items := f.itemsByFilter[key]
if limit, err := strconv.Atoi(params.Get("Limit")); err == nil && limit > 0 && len(items) > limit {
items = items[:limit]
}
return &emby.ItemsResult{Items: items}, nil
}
func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) {
@@ -75,6 +114,69 @@ func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID strin
return &emby.ItemsResult{Items: f.similar[itemID]}, nil
}
func (f *fakeSource) NextUp(
_ context.Context,
_ emby.Credentials,
_ url.Values,
) (*emby.ItemsResult, error) {
if f.nextUpErr != nil {
return nil, f.nextUpErr
}
return &emby.ItemsResult{Items: f.nextUp}, nil
}
func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) {
now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
catalogue := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
})
session := func(show string, season, episode, daysAgo int) tracearr.Session {
value := tracearr.Session{
ID: "session-" + show, MediaType: "episode", ShowTitle: show,
SeasonNumber: intPointer(season), EpisodeNumber: intPointer(episode),
Watched: true, StoppedAt: now.AddDate(0, 0, -daysAgo).Format(time.RFC3339Nano),
}
return value
}
sessions := []tracearr.Session{
session("Early Show", 1, 2, 40),
session("Deep Show", 2, 8, 60),
session("Complete Show", 4, 10, 50),
session("Recent Show", 1, 3, 5),
}
nextUp := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
})
got := abandonedShowCandidates(
newCatalogueIndex(catalogue),
sessions,
nextUp,
compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
now,
)
if len(got) != 2 {
t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got)
}
if got[0].ItemID != "deep" || got[1].ItemID != "early" {
t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID)
}
if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" {
t.Fatalf("later-season reason = %q", got[0].RecommendationReason)
}
if got[1].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("season-one reason = %q", got[1].RecommendationReason)
}
if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 {
t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes)
}
}
func raw(id, name, itemType string, genres ...string) json.RawMessage {
quoted := make([]string, 0, len(genres))
for _, g := range genres {
@@ -128,6 +230,117 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
}
}
func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsResumable": {},
"IsPlayed": {
json.RawMessage(`{"Id":"seen","Name":"Arrival","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
},
"IsFavorite": {},
},
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
json.RawMessage(`{"Id":"short","Name":"Moon","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000,"MediaStreams":[{"Type":"Video","Codec":"h264"}]}`),
json.RawMessage(`{"Id":"long","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":93000000000,"MediaStreams":[{"Type":"Video","Codec":"hevc"}]}`),
}}
session := tracearr.Session{
MediaType: "movie",
MediaTitle: "Arrival",
Watched: true,
Platform: "Android TV",
SourceVideoCodec: "h264",
VideoDecision: "directplay",
}
engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
engine.MinRowItems = 1
rows, err := engine.BuildForYou(
context.Background(),
emby.Credentials{UserID: "u1"},
"Matt",
ForYouOptions{AvailableMinutes: 100},
)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || len(rows[0].Items) != 1 {
t.Fatalf("rows = %+v", rows)
}
if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason"`) ||
!strings.Contains(string(rows[0].Items[0]), `100-minute window`) {
t.Fatalf("explanation missing: %s", rows[0].Items[0])
}
if strings.Contains(string(rows[0].Items[0]), `"Id":"long"`) {
t.Fatalf("over-budget item was retained: %s", rows[0].Items[0])
}
}
func TestPrepareForYouKeepsAnOverProvisionedPoolAndSpecificEvidence(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsResumable": {},
"IsPlayed": {
json.RawMessage(`{"Id":"watched","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"UserData":{"Played":true}}`),
},
"IsFavorite": {},
}}
catalogue := []json.RawMessage{
json.RawMessage(`{"Id":"arrival","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
}
for i := 0; i < 30; i++ {
catalogue = append(catalogue, json.RawMessage(
`{"Id":"candidate-`+strconv.Itoa(i)+`","Name":"Candidate `+strconv.Itoa(i)+
`","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000}`,
))
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: catalogue}
session := tracearr.Session{
ID: "trace-1", ServerID: "server-1", MediaTitle: "Arrival",
MediaType: "movie", Year: intPointer(2016), Watched: true,
StartedAt: "2026-07-20T08:00:00Z",
}
session.User.ID = "trace-user"
session.User.Username = "Matt"
result, err := engine.PrepareForYou(
context.Background(), emby.Credentials{UserID: "emby-user"}, "Matt",
[]tracearr.Session{session},
)
if err != nil {
t.Fatalf("PrepareForYou: %v", err)
}
if len(result.Candidates) < 30 {
t.Fatalf("prepared pool was prematurely row-sized: %d candidates", len(result.Candidates))
}
foundSpecific := false
for _, candidate := range result.Candidates {
if strings.Contains(candidate.RecommendationReason, "Because you finished Arrival") {
foundSpecific = true
break
}
}
if !foundSpecific {
t.Fatal("expected a candidate explanation grounded in the completed Tracearr title")
}
if result.Profile.TracearrUserID != "trace-user" || len(result.Mappings) != 1 {
t.Fatalf("profile/mapping = %+v / %+v", result.Profile, result.Mappings)
}
}
func intPointer(value int) *int { return &value }
func TestStableEvidenceIndexDistributesCandidatesAcrossCompletedTitles(t *testing.T) {
seen := map[int]bool{}
for i := 0; i < 20; i++ {
seen[stableEvidenceIndex("candidate-"+strconv.Itoa(i), 3)] = true
}
if len(seen) != 3 {
t.Fatalf("evidence indices = %+v, want all three sources represented", seen)
}
}
func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
@@ -179,6 +392,123 @@ func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) {
}
}
func TestBuildRowsExcludesPlayedTitlesBeyondTheOldHistoryWindow(t *testing.T) {
played := make([]json.RawMessage, 0, 61)
for i := 0; i < 60; i++ {
played = append(played, raw("history-"+strconv.Itoa(i), "History", "Movie", "Drama"))
}
played = append(played, raw("old-watched", "Old Watched", "Movie", "Drama"))
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{"IsPlayed": played},
similar: map[string][]json.RawMessage{},
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
raw("old-watched", "Old Watched", "Movie", "Drama"),
raw("new-pick", "New Pick", "Movie", "Drama"),
}}
engine.MinRowItems = 1
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatalf("BuildRows: %v", err)
}
for _, row := range rows {
for _, candidate := range row.Items {
if strings.Contains(string(candidate), `"Id":"old-watched"`) {
t.Fatalf("row %q retained a title older than the previous 60-item exclusion window", row.ID)
}
}
}
}
func TestBuildForYouExcludesTracearrCompletedTitleMissingFromEmbyHistory(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("taste", "Taste", "Movie", "Science Fiction")},
}}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
json.RawMessage(`{"Id":"watched-copy","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"]}`),
raw("unseen", "Moon", "Movie", "Science Fiction"),
}}
year := 2016
session := tracearr.Session{
MediaType: "movie", MediaTitle: "Arrival", Year: &year, Watched: true,
}
engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
engine.MinRowItems = 1
rows, err := engine.BuildForYou(
context.Background(), emby.Credentials{UserID: "u1"}, "FamilyTV", ForYouOptions{},
)
if err != nil {
t.Fatalf("BuildForYou: %v", err)
}
if len(rows) != 1 || len(rows[0].Items) != 1 ||
!strings.Contains(string(rows[0].Items[0]), `"Id":"unseen"`) {
t.Fatalf("Tracearr-completed title was not excluded: %+v", rows)
}
}
func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Drama": 2, "Science Fiction": 1},
Seen: map[string]bool{}, SeenTitles: map[string]bool{},
}
evidence := map[string][]PreparedEvidence{
"drama": {{
ItemID: "arrival", Title: "Arrival",
Genres: []string{"Drama", "Science Fiction"},
}},
}
counts := map[string]int{}
kinds := map[string]int{}
for i := 0; i < 20; i++ {
candidate := item(
"candidate-"+strconv.Itoa(i), "Candidate", "Movie",
[]string{"Drama", "Science Fiction"}, 7,
)
_, _, kind, _, _ := explainPreparedRecommendation(
profile, candidate, compatibilityProfile{}, false, evidence, counts,
)
kinds[kind]++
}
if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 {
t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"])
}
if kinds["genre"] == 0 {
t.Fatalf("reason kinds were not mixed: %+v", kinds)
}
}
func TestPreparedExplanationRejectsOneBroadGenreAsSpecificEvidence(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Drama": 2},
Seen: map[string]bool{}, SeenTitles: map[string]bool{},
}
evidence := map[string][]PreparedEvidence{
"drama": {{ItemID: "source", Title: "Source", Genres: []string{"Drama"}}},
}
_, _, kind, _, _ := explainPreparedRecommendation(
profile, item("candidate", "Candidate", "Movie", []string{"Drama"}, 7),
compatibilityProfile{}, false, evidence, map[string]int{},
)
if kind != "genre" {
t.Fatalf("one broad shared genre produced %q, want genre", kind)
}
}
func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
sessions := []tracearr.Session{
{MediaTitle: "PreRoll_Swirls"},
{MediaTitle: "A Real Film"},
}
got := recommendationSessions(sessions)
if len(got) != 1 || got[0].MediaTitle != "A Real Film" {
t.Fatalf("recommendation sessions = %+v", got)
}
}
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
@@ -256,6 +586,130 @@ func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
}
}
func TestDefaultCuratedRowsIncludePersonalizedShowGenres(t *testing.T) {
engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
got := map[string]string{}
for _, row := range engine.CuratedRows {
got[row.ID] = row.Title
}
for id, title := range map[string]string{
"curated:comedy-shows": "Comedy Shows",
"curated:drama-shows": "Drama Shows",
"curated:horror-shows": "Horror Shows",
} {
if got[id] != title {
t.Fatalf("%s title = %q, want %q", id, got[id], title)
}
}
}
func TestDefaultCuratedRowsIncludeMovieGenresAndStudioFamilies(t *testing.T) {
engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
got := map[string]CuratedRow{}
for _, row := range engine.CuratedRows {
got[row.ID] = row
}
for _, id := range []string{
"curated:movies:genre:science-fiction",
"curated:movies:genre:animation",
"curated:movies:studio:pixar",
"curated:movies:studio:disney",
} {
row, ok := got[id]
if !ok {
t.Fatalf("missing default movie shelf %q", id)
}
if row.Kind != "movies" || !row.RequireAffinity {
t.Fatalf("movie shelf %q = %+v", id, row)
}
}
}
func TestMovieShelvesRequireAffinityAndRankAStudioBeforeItsGenre(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {
json.RawMessage(`{"Id":"watched","Name":"Toy Story","Type":"Movie","Genres":["Comedy"],"Studios":[{"Name":"Pixar Animation Studios"}]}`),
},
}}
engine := testEngine(source)
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("comedy-1", "Comedy One", "Movie", "Comedy"),
raw("comedy-2", "Comedy Two", "Movie", "Comedy"),
},
"Drama": {
raw("drama-1", "Drama One", "Movie", "Drama"),
raw("drama-2", "Drama Two", "Movie", "Drama"),
},
"studio:Pixar": {
raw("pixar-1", "Pixar One", "Movie", "Animation"),
raw("pixar-2", "Pixar Two", "Movie", "Animation"),
},
}}
engine.CuratedRows = []CuratedRow{
movieGenreRow("comedy", "Comedy"),
movieGenreRow("drama", "Drama"),
movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
curated := make([]Row, 0, len(rows))
for _, row := range rows {
if strings.HasPrefix(row.ID, "curated:movies:") {
curated = append(curated, row)
}
}
if len(curated) != 2 {
t.Fatalf("movie shelves = %+v, want Pixar and Comedy only", rowTitles(curated))
}
if curated[0].ID != "curated:movies:studio:pixar" ||
curated[1].ID != "curated:movies:genre:comedy" {
t.Fatalf("movie shelf order = %+v", rowTitles(curated))
}
}
func TestCuratedRowsDoNotRepeatCardsAcrossGenres(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("history", "Funny", "Episode", "Comedy")},
}}
engine := testEngine(source)
engine.MinRowItems = 1
engine.RowSize = 3
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
raw("comedy", "Comedy Only", "Series", "Comedy"),
},
"Drama": {
raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
raw("drama", "Drama Only", "Series", "Drama"),
},
}}
engine.CuratedRows = []CuratedRow{
{ID: "comedy", Title: "Comedy Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
{ID: "drama", Title: "Drama Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
ids := map[string]int{}
for _, row := range rows {
for _, item := range Decode(row.Items) {
ids[item.ID]++
}
}
if ids["shared"] != 1 {
t.Fatalf("shared card appeared %d times across curated rows", ids["shared"])
}
}
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
engine := testEngine(source)
+85 -12
View File
@@ -10,7 +10,9 @@ import (
"encoding/json"
"math"
"sort"
"strconv"
"strings"
"unicode"
)
// recencyDecay is applied per position down the history list. At 0.94, the 12th item
@@ -26,14 +28,23 @@ const favoriteWeight = 0.6
// Item is the slice of an Emby item this package reasons about. The raw payload rides
// along so rows can be emitted without re-fetching or re-encoding.
type Item struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
Studios []struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
ProductionYear int `json:"ProductionYear"`
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
RunTimeTicks int64 `json:"RunTimeTicks"`
IndexNumber int `json:"IndexNumber"`
ParentIndexNumber int `json:"ParentIndexNumber"`
Container string `json:"Container"`
MediaStreams []struct {
Type string `json:"Type"`
Codec string `json:"Codec"`
} `json:"MediaStreams"`
Studios []struct {
Name string `json:"Name"`
} `json:"Studios"`
UserData struct {
@@ -46,6 +57,38 @@ type Item struct {
Raw json.RawMessage `json:"-"`
}
func (i Item) RuntimeMinutes() int {
if i.RunTimeTicks <= 0 {
return 0
}
return int(i.RunTimeTicks / 600_000_000)
}
func (i Item) TitleKey() string {
value := i.Name
if i.Type == "Episode" && strings.TrimSpace(i.SeriesName) != "" {
value = i.SeriesName
}
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// SeenKey is a title-level fallback for imported catalogue records, whose payloads
// deliberately contain no per-user UserData. Movies include their year so watching an
// older film does not hide a remake with the same name; episodes collapse to series.
func (i Item) SeenKey() string {
key := i.TitleKey()
if key != "" && strings.EqualFold(i.Type, "Movie") && i.ProductionYear > 0 {
return key + "|" + strconv.Itoa(i.ProductionYear)
}
return key
}
// Seed is a title recent enough to anchor a "Because you watched …" row.
type Seed struct {
ID string
@@ -58,8 +101,9 @@ type Profile struct {
StudioWeights map[string]float64
// Seen holds item ids *and* series ids already watched or in progress, so a
// recommendation never suggests something the user is already partway through.
Seen map[string]bool
Seeds []Seed
Seen map[string]bool
SeenTitles map[string]bool
Seeds []Seed
}
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
@@ -87,12 +131,25 @@ func BuildProfile(history, favorites []Item) Profile {
GenreWeights: map[string]float64{},
StudioWeights: map[string]float64{},
Seen: map[string]bool{},
SeenTitles: map[string]bool{},
}
seedSeen := map[string]bool{}
tasteSeen := map[string]bool{}
for i, item := range history {
weight := math.Pow(recencyDecay, float64(i))
profile.absorb(item, weight)
profile.markSeen(item)
// Several episodes of one series are evidence for one taste, not several
// independent tastes. Keep the newest occurrence's recency weight and still
// mark every item/series identifier as seen.
tasteID := item.ID
if item.SeriesID != "" {
tasteID = item.SeriesID
}
if !tasteSeen[tasteID] {
tasteSeen[tasteID] = true
profile.absorbTaste(item, math.Pow(recencyDecay, float64(i)))
}
// An episode seeds its series, not itself: "Because you watched Severance"
// reads better than "Because you watched Good News".
@@ -113,12 +170,25 @@ func BuildProfile(history, favorites []Item) Profile {
}
func (p *Profile) absorb(item Item, weight float64) {
p.markSeen(item)
p.absorbTaste(item, weight)
}
func (p *Profile) markSeen(item Item) {
if item.ID != "" {
p.Seen[item.ID] = true
}
if item.SeriesID != "" {
p.Seen[item.SeriesID] = true
}
if key := item.SeenKey(); key != "" {
p.SeenTitles[key] = true
}
}
// absorbTaste learns affinity without marking the item watched. This is used for
// browsing signals: lingering on a card is meaningful, but must not hide that card.
func (p *Profile) absorbTaste(item Item, weight float64) {
for _, genre := range item.Genres {
if g := strings.TrimSpace(genre); g != "" {
p.GenreWeights[g] += weight
@@ -168,6 +238,9 @@ func (p Profile) Score(candidate Item) float64 {
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
return -1
}
if p.SeenTitles[candidate.SeenKey()] {
return -1
}
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
return -1
}
+17
View File
@@ -59,6 +59,23 @@ func TestBuildProfileDeduplicatesSeeds(t *testing.T) {
}
}
func TestBuildProfileDoesNotCountEveryEpisodeAsAnotherTasteVote(t *testing.T) {
history := []Item{
episode("ep2", "Second", "series", "Series", []string{"Drama"}),
episode("ep1", "First", "series", "Series", []string{"Drama"}),
item("movie", "Movie", "Movie", []string{"Comedy"}, 0),
}
profile := BuildProfile(history, nil)
if profile.GenreWeights["Drama"] != 1 {
t.Fatalf("repeated series weight = %v, want the newest occurrence only",
profile.GenreWeights["Drama"])
}
if !profile.Seen["ep1"] || !profile.Seen["ep2"] || !profile.Seen["series"] {
t.Fatalf("episode/series exclusions were lost: %+v", profile.Seen)
}
}
func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) {
fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})
+39
View File
@@ -2,12 +2,51 @@ package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// BrowsingCandidates returns library items the user actively focused or selected,
// strongest first. Impressions are intentionally excluded: merely scrolling past a row
// is not evidence of taste.
func (s *Store) BrowsingCandidates(
ctx context.Context,
userID string,
since time.Time,
limit int,
) ([]json.RawMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT li.payload
FROM row_events re
JOIN library_items li ON li.id = re.item_id
WHERE re.emby_user_id = $1
AND re.occurred_at >= $2
AND re.event IN ('focus', 'select')
GROUP BY li.id, li.payload
ORDER BY
count(*) FILTER (WHERE re.event = 'select') * 20 +
count(*) FILTER (WHERE re.event = 'focus') * 2 +
coalesce(sum(re.dwell_ms), 0) / 10000 DESC,
max(re.occurred_at) DESC
LIMIT $3`, userID, since, limit)
if err != nil {
return nil, fmt.Errorf("store: browsing candidates: %w", err)
}
defer rows.Close()
out := []json.RawMessage{}
for rows.Next() {
var payload []byte
if err := rows.Scan(&payload); err != nil {
return nil, err
}
out = append(out, json.RawMessage(payload))
}
return out, rows.Err()
}
// RowEvent is one reported interaction with a home-screen row.
type RowEvent struct {
OccurredAt time.Time
+15
View File
@@ -138,6 +138,21 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
return collectPayloads(rows)
}
// AllRecommendationCandidates returns the complete Movie/Series catalogue for an
// offline For You rebuild. The resulting per-user pool is deliberately over-provisioned
// so a short runtime filter still has enough ranked titles to fill the TV row.
func (s *Store) AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE type IN ('Movie', 'Series')
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST`)
if err != nil {
return nil, fmt.Errorf("store: all recommendation candidates: %w", err)
}
return collectPayloads(rows)
}
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
// are matched case-insensitively because Emby studio capitalisation is not consistent.
func (s *Store) CuratedCandidates(
+117
View File
@@ -11,11 +11,15 @@ CREATE TABLE IF NOT EXISTS sessions (
server_id TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '',
device_name TEXT NOT NULL DEFAULT 'Memby TV',
client_version TEXT NOT NULL DEFAULT '',
client_protocol TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT '';
-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
@@ -102,3 +106,116 @@ CREATE TABLE IF NOT EXISTS row_events (
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
-- Search terms are retained separately from row engagement so they can inform future
-- ranking/recommendation work without coupling that analysis to rendered rows.
CREATE TABLE IF NOT EXISTS search_history (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL,
query TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS search_history_user_time_idx
ON search_history (emby_user_id, occurred_at DESC);
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
CREATE TABLE IF NOT EXISTS tracearr_sessions (
server_id TEXT NOT NULL DEFAULT '',
tracearr_session_id TEXT NOT NULL,
tracearr_user_id TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
media_type TEXT NOT NULL DEFAULT '',
media_title TEXT NOT NULL DEFAULT '',
show_title TEXT NOT NULL DEFAULT '',
season_number INT,
episode_number INT,
production_year INT,
started_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
duration_ms BIGINT NOT NULL DEFAULT 0,
progress_ms BIGINT NOT NULL DEFAULT 0,
total_duration_ms BIGINT NOT NULL DEFAULT 0,
watched BOOLEAN NOT NULL DEFAULT false,
device TEXT NOT NULL DEFAULT '',
player TEXT NOT NULL DEFAULT '',
product TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
is_transcode BOOLEAN NOT NULL DEFAULT false,
video_decision TEXT NOT NULL DEFAULT '',
audio_decision TEXT NOT NULL DEFAULT '',
source_video_codec TEXT NOT NULL DEFAULT '',
source_audio_codec TEXT NOT NULL DEFAULT '',
emby_item_id TEXT NOT NULL DEFAULT '',
emby_series_id TEXT NOT NULL DEFAULT '',
source_fingerprint BYTEA NOT NULL,
source_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (server_id, tracearr_session_id)
);
CREATE INDEX IF NOT EXISTS tracearr_sessions_user_time_idx
ON tracearr_sessions (tracearr_user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS tracearr_sessions_username_time_idx
ON tracearr_sessions (lower(username), started_at DESC);
CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_item_idx
ON tracearr_sessions (emby_item_id) WHERE emby_item_id <> '';
CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_series_idx
ON tracearr_sessions (emby_series_id) WHERE emby_series_id <> '';
-- One compact derived profile per Emby user. Variable affinity maps stay together as
-- JSON because the builder reads and replaces the whole profile; no request filters
-- inside these maps.
CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
emby_user_id TEXT PRIMARY KEY,
tracearr_user_id TEXT NOT NULL DEFAULT '',
tracearr_username TEXT NOT NULL DEFAULT '',
source_session_count INT NOT NULL DEFAULT 0,
mean_completion_ratio REAL NOT NULL DEFAULT 0,
typical_session_minutes INT NOT NULL DEFAULT 0,
genre_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
title_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
studio_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
signals_through TIMESTAMPTZ,
built_at TIMESTAMPTZ,
pool_built_at TIMESTAMPTZ,
dirty_since TIMESTAMPTZ DEFAULT now(),
last_error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS recommendation_profiles_dirty_idx
ON recommendation_user_profiles (dirty_since)
WHERE dirty_since IS NOT NULL;
-- Every eligible ranked title is retained. At household scale this is only tens of
-- thousands of compact rows and gives short runtime filters far more headroom than the
-- old 240-title request pool.
CREATE TABLE IF NOT EXISTS for_you_candidates (
emby_user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
base_rank INT NOT NULL,
base_score REAL NOT NULL DEFAULT 0,
runtime_minutes INT NOT NULL DEFAULT 0,
affinity_score REAL NOT NULL DEFAULT 0,
compatibility_score REAL NOT NULL DEFAULT 0,
compatibility_label TEXT NOT NULL DEFAULT '',
reason_kind TEXT NOT NULL DEFAULT '',
reason_genre TEXT NOT NULL DEFAULT '',
reason_source_session_id TEXT NOT NULL DEFAULT '',
reason_source_item_id TEXT NOT NULL DEFAULT '',
reason_source_title TEXT NOT NULL DEFAULT '',
recommendation_reason TEXT NOT NULL DEFAULT '',
built_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, item_id),
FOREIGN KEY (item_id) REFERENCES library_items(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS for_you_candidates_user_rank_idx
ON for_you_candidates (emby_user_id, base_rank);
CREATE INDEX IF NOT EXISTS for_you_candidates_user_runtime_rank_idx
ON for_you_candidates (emby_user_id, runtime_minutes, base_rank);
+4 -2
View File
@@ -104,10 +104,12 @@ func (s *Store) SetUpdatePolicy(ctx context.Context, policy appupdate.Policy) er
func (s *Store) NewestSession(ctx context.Context) (Session, error) {
var sess Session
err := s.pool.QueryRow(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at
SELECT 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 last_seen_at DESC LIMIT 1`).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
+94 -16
View File
@@ -20,14 +20,16 @@ var ErrNotFound = errors.New("store: session not found")
var ErrDeviceLimit = errors.New("store: device limit reached")
type Session struct {
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
DeviceName string
LastSeenAt time.Time
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
DeviceName string
ClientVersion string
ClientProtocol string
LastSeenAt time.Time
}
type Store struct {
@@ -50,6 +52,58 @@ func (s *Store) Close() { s.pool.Close() }
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
// RecordSearch stores a normalized query for future per-user ranking analysis.
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
_, err := s.pool.Exec(ctx,
`WITH inserted AS (
INSERT INTO search_history (emby_user_id, query) VALUES ($1, $2)
RETURNING id
)
DELETE FROM search_history
WHERE emby_user_id = $1
AND occurred_at < now() - interval '30 days'`,
userID, query)
return err
}
// RecentSearches returns a user's distinct queries in most-recently-used order.
// Case-only duplicates collapse to the spelling used most recently.
func (s *Store) RecentSearches(
ctx context.Context,
userID string,
since time.Time,
limit int,
) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT query
FROM (
SELECT DISTINCT ON (lower(query)) query, occurred_at
FROM search_history
WHERE emby_user_id = $1 AND occurred_at >= $2
ORDER BY lower(query), occurred_at DESC
) AS latest
ORDER BY occurred_at DESC
LIMIT $3`,
userID, since, limit)
if err != nil {
return nil, fmt.Errorf("store: recent searches: %w", err)
}
defer rows.Close()
queries := make([]string, 0, limit)
for rows.Next() {
var query string
if err := rows.Scan(&query); err != nil {
return nil, fmt.Errorf("store: scan recent search: %w", err)
}
queries = append(queries, query)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: read recent searches: %w", err)
}
return queries, nil
}
// Migrate applies the schema. It is idempotent, so it runs on every boot.
func (s *Store) Migrate(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, schema); err != nil {
@@ -94,18 +148,21 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
_, err = tx.Exec(ctx, `
INSERT INTO sessions (
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name,
client_version, client_protocol
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
token_hash = EXCLUDED.token_hash,
emby_token = EXCLUDED.emby_token,
username = EXCLUDED.username,
server_id = EXCLUDED.server_id,
device_name = EXCLUDED.device_name,
client_version = EXCLUDED.client_version,
client_protocol = EXCLUDED.client_protocol,
last_seen_at = now()`,
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
sess.ServerID, sess.DeviceID, sess.DeviceName)
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol)
if err != nil {
return nil, 0, fmt.Errorf("store: create session: %w", err)
}
@@ -121,10 +178,12 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
var sess Session
err := s.pool.QueryRow(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name, last_seen_at
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, last_seen_at
FROM sessions WHERE token_hash = $1`, hash).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt)
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
@@ -141,6 +200,23 @@ func (s *Store) Touch(ctx context.Context, hash []byte) error {
return err
}
// UpdateSessionClientIdentity remembers the last non-empty identity supplied by a TV.
// Headerless image requests can then still be attributed to the correct app build.
func (s *Store) UpdateSessionClientIdentity(
ctx context.Context,
hash []byte,
version, protocol string,
) error {
_, err := s.pool.Exec(ctx, `
UPDATE sessions
SET client_version = CASE WHEN $2 <> '' THEN $2 ELSE client_version END,
client_protocol = CASE WHEN $3 <> '' THEN $3 ELSE client_protocol END,
last_seen_at = now()
WHERE token_hash = $1`,
hash, version, protocol)
return err
}
func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash)
return err
@@ -176,10 +252,11 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess
AND ranked.device_rank > $1
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
current.username, current.server_id, current.device_id,
current.device_name, current.last_seen_at
current.device_name, current.client_version, current.client_protocol,
current.last_seen_at
)
SELECT token_hash, emby_user_id, emby_token, username, server_id,
device_id, device_name, last_seen_at
device_id, device_name, client_version, client_protocol, last_seen_at
FROM retired`,
maxClients,
)
@@ -193,7 +270,8 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess
var sess Session
if err := rows.Scan(
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
}