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,