Big changes
This commit is contained in:
+107
-15
@@ -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
@@ -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) => ({
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
}[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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) + `"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
-1
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user