Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
7.9 KiB
Go
249 lines
7.9 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"embed"
|
||
"fmt"
|
||
"html/template"
|
||
"io/fs"
|
||
"path"
|
||
"sort"
|
||
)
|
||
|
||
// The console used to be one HTML file holding every screen at once: a television opening
|
||
// /admin/logs was still sent the accounts settings editor, the feature grid and the
|
||
// recommendation inspector, all hidden. That is why it read as one page wearing twelve
|
||
// hats. It is now a shell plus one fragment per page, composed here at start-up, so a page
|
||
// carries only its own markup and its own script — and so the rail, the page titles and
|
||
// the set of legal URLs all come from adminNav rather than being written out three times.
|
||
//
|
||
// The no-build, no-CDN rule is unchanged: everything below is embedded in the binary and
|
||
// inlined into the response. Nothing is fetched from anywhere.
|
||
|
||
//go:embed admin/shell.html admin/admin.css admin/core.js admin/pages
|
||
var adminAssets embed.FS
|
||
|
||
// adminNavItem is one destination. Hidden items are reachable and titled but are not in
|
||
// the rail — an account's own page belongs to the person it is about, not to a menu.
|
||
type adminNavItem struct {
|
||
ID string
|
||
Label string
|
||
Title string
|
||
Intro string
|
||
Icon string // the `d` of a single stroked path, drawn on a 24×24 grid
|
||
Hidden bool
|
||
}
|
||
|
||
type adminNavGroup struct {
|
||
Label string
|
||
Items []adminNavItem
|
||
}
|
||
|
||
// adminNav is the console's table of contents and the only place a page is declared.
|
||
// Adding a page is an entry here plus admin/pages/<id>.html and admin/pages/<id>.js.
|
||
var adminNav = []adminNavGroup{
|
||
{
|
||
Items: []adminNavItem{
|
||
{
|
||
ID: "overview", Label: "Overview", Title: "Overview",
|
||
Intro: "What the gateway is doing right now.",
|
||
Icon: "M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
Label: "People",
|
||
Items: []adminNavItem{
|
||
{
|
||
ID: "accounts", Label: "Users", Title: "Memby users",
|
||
Intro: "Who uses Memby, and the devices they are signed in on.",
|
||
Icon: "M16 19v-1.5A3.5 3.5 0 0 0 12.5 14h-5A3.5 3.5 0 0 0 4 17.5V19M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-2h3m-1.5-1.5v3",
|
||
},
|
||
{
|
||
ID: "account", Title: "User", Hidden: true,
|
||
Intro: "Devices, recommendation setup and synced settings for one person.",
|
||
},
|
||
{
|
||
ID: "settings-history", Title: "Settings history", Hidden: true,
|
||
Intro: "Every change to one person's synced settings, and which devices took it.",
|
||
},
|
||
{
|
||
ID: "clients", Label: "Devices", Title: "Devices",
|
||
Intro: "Which sets have reported in, what they are running and what their build understands.",
|
||
Icon: "M4 5h16v10H4zM9 19h6M12 15v4",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
Label: "Content",
|
||
Items: []adminNavItem{
|
||
{
|
||
ID: "library", Label: "Library", Title: "Library",
|
||
Intro: "Import and inspect the catalogue Memby ranks.",
|
||
Icon: "M4 5.5h16v13H4zM8 5.5v13M4 10h4",
|
||
},
|
||
{
|
||
ID: "ratings", Label: "Movie ratings", Title: "Movie ratings",
|
||
Intro: "Optional MDBList scores on films and shows.",
|
||
Icon: "m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z",
|
||
},
|
||
{
|
||
ID: "requests", Label: "Media requests", Title: "Media requests",
|
||
Intro: "Who can ask for something the library does not have.",
|
||
Icon: "M5 4h14v16H5zM8 8h8M8 12h5M15 16h3m-1.5-1.5v3",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
Label: "Personalisation",
|
||
Items: []adminNavItem{
|
||
{
|
||
ID: "recommendations", Label: "For You", Title: "For You",
|
||
Intro: "The prepared pools personalised rows are drawn from.",
|
||
Icon: "m12 3 1.5 5 5 .2-4 3 1.4 5-3.9-2.8-3.9 2.8 1.4-5-4-3 5-.2L12 3Z",
|
||
},
|
||
{
|
||
ID: "inspector", Label: "Score inspector", Title: "Score inspector",
|
||
Intro: "Re-run the ranker for one person and read every component.",
|
||
Icon: "M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
Label: "Experience",
|
||
Items: []adminNavItem{
|
||
{
|
||
ID: "features", Label: "Features", Title: "Features",
|
||
Intro: "Roll out, stop and recover optional behaviour with no app release.",
|
||
Icon: "M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6",
|
||
},
|
||
{
|
||
ID: "playback", Label: "Playback", Title: "Playback",
|
||
Intro: "Presentation policy sent with every playback launch.",
|
||
Icon: "M8 5v14l11-7zM4 5v14",
|
||
},
|
||
{
|
||
ID: "updates", Label: "App updates", Title: "App updates",
|
||
Intro: "Publish an optional or a required client update.",
|
||
Icon: "M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
Label: "Operations",
|
||
Items: []adminNavItem{
|
||
{
|
||
ID: "maintenance", Label: "Maintenance", Title: "Maintenance",
|
||
Intro: "Take Memby offline for every television.",
|
||
Icon: "m14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z",
|
||
},
|
||
{
|
||
ID: "imports", Label: "Imports", Title: "Imports",
|
||
Intro: "Catalogue synchronisation history.",
|
||
Icon: "M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5",
|
||
},
|
||
{
|
||
ID: "engagement", Label: "Row engagement", Title: "Row engagement",
|
||
Intro: "Impressions, focus, dwell and selections per launcher row.",
|
||
Icon: "M4 19V9m5 10V5m5 14v-7m5 7V3",
|
||
},
|
||
{
|
||
ID: "logs", Label: "Server logs", Title: "Server logs",
|
||
Intro: "Structured gateway events as they happen.",
|
||
Icon: "M4 5h16v14H4zM7 9l2 2-2 2m5 1h5",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
// adminPages is the set of legal /admin/<page> URLs, derived so a page cannot exist in the
|
||
// rail and 404 — or be reachable and unnamed. installer_auth reads it to decide which
|
||
// destination a sign-in may return to, which is why hidden pages are excluded: they are
|
||
// addressed by a route that carries something else in the path, and a sign-in that returned
|
||
// to one without it would land on a page about nobody.
|
||
var adminPages = func() map[string]bool {
|
||
pages := map[string]bool{}
|
||
forEachAdminPage(func(item adminNavItem) {
|
||
if !item.Hidden {
|
||
pages[item.ID] = true
|
||
}
|
||
})
|
||
return pages
|
||
}()
|
||
|
||
func forEachAdminPage(visit func(adminNavItem)) {
|
||
for _, group := range adminNav {
|
||
for _, item := range group.Items {
|
||
visit(item)
|
||
}
|
||
}
|
||
}
|
||
|
||
type adminShellData struct {
|
||
Page adminNavItem
|
||
Nav []adminNavGroup
|
||
CSS template.CSS
|
||
Core template.JS
|
||
Body template.HTML
|
||
Script template.JS
|
||
}
|
||
|
||
// adminRendered holds every page as finished bytes. Composition happens once, at start-up,
|
||
// so serving a page is a write of a []byte exactly as it was when the whole console was
|
||
// one file.
|
||
var adminRendered = buildAdminPages()
|
||
|
||
func buildAdminPages() map[string][]byte {
|
||
shell := template.Must(template.New("shell").ParseFS(adminAssets, "admin/shell.html"))
|
||
css := template.CSS(mustReadAdminAsset("admin/admin.css"))
|
||
core := template.JS(mustReadAdminAsset("admin/core.js"))
|
||
|
||
rendered := map[string][]byte{}
|
||
forEachAdminPage(func(item adminNavItem) {
|
||
var out bytes.Buffer
|
||
err := shell.ExecuteTemplate(&out, "shell.html", adminShellData{
|
||
Page: item, Nav: adminNav, CSS: css, Core: core,
|
||
Body: template.HTML(mustReadAdminAsset(path.Join("admin/pages", item.ID+".html"))),
|
||
Script: template.JS(mustReadAdminAsset(path.Join("admin/pages", item.ID+".js"))),
|
||
})
|
||
if err != nil {
|
||
panic(fmt.Sprintf("admin console: render %s: %v", item.ID, err))
|
||
}
|
||
rendered[item.ID] = out.Bytes()
|
||
})
|
||
assertEveryAdminFragmentIsRouted(rendered)
|
||
return rendered
|
||
}
|
||
|
||
func mustReadAdminAsset(name string) string {
|
||
data, err := adminAssets.ReadFile(name)
|
||
if err != nil {
|
||
panic(fmt.Sprintf("admin console: %v", err))
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
// A fragment nobody routes to is dead weight that still looks maintained. Catching it here
|
||
// means a page removed from adminNav takes its files with it, or fails at start-up.
|
||
func assertEveryAdminFragmentIsRouted(rendered map[string][]byte) {
|
||
entries, err := fs.ReadDir(adminAssets, "admin/pages")
|
||
if err != nil {
|
||
panic(fmt.Sprintf("admin console: %v", err))
|
||
}
|
||
var orphaned []string
|
||
for _, entry := range entries {
|
||
name := entry.Name()
|
||
if path.Ext(name) != ".html" {
|
||
continue
|
||
}
|
||
id := name[:len(name)-len(".html")]
|
||
if _, ok := rendered[id]; !ok {
|
||
orphaned = append(orphaned, id)
|
||
}
|
||
}
|
||
if len(orphaned) > 0 {
|
||
sort.Strings(orphaned)
|
||
panic(fmt.Sprintf("admin console: pages with no nav entry: %v", orphaned))
|
||
}
|
||
}
|