App v0.2.26 and gateway 0.1.20

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>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+171
View File
@@ -0,0 +1,171 @@
package logging
import (
"context"
"io"
"log/slog"
"slices"
"strconv"
"strings"
"sync"
"time"
)
// levelWidth and messageWidth keep the fields of consecutive lines in the same columns,
// which is what makes a scrolling log readable at a glance. Both are minimums: a longer
// message pushes its fields right rather than being truncated.
const (
levelWidth = 5
messageWidth = 30
)
// consoleHandler writes one aligned line per event — timestamp, level, message, fields.
//
// The timestamp leads the line rather than arriving as a `time=` field. Everything that
// reads these lines (docker compose logs, the admin page, a NAS console) already treats
// the start of a line as when it happened, so a labelled key there is noise sitting in
// front of the message.
type consoleHandler struct {
// mu is shared by every derived handler: WithAttrs clones this struct, and two
// clones writing to the same file must still produce whole lines.
mu *sync.Mutex
w io.Writer
level slog.Leveler
attrs []field
groups []string
}
type field struct {
key string
value string
}
func newConsoleHandler(w io.Writer, level slog.Leveler) *consoleHandler {
return &consoleHandler{mu: &sync.Mutex{}, w: w, level: level}
}
func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level.Level()
}
func (h *consoleHandler) Handle(_ context.Context, record slog.Record) error {
fields := slices.Clone(h.attrs)
record.Attrs(func(attr slog.Attr) bool {
fields = appendField(fields, h.groups, attr)
return true
})
orderFields(fields)
var line strings.Builder
line.WriteString(record.Time.UTC().Format(time.DateTime))
line.WriteByte(' ')
line.WriteString(pad(record.Level.String(), levelWidth))
line.WriteByte(' ')
line.WriteString(pad(record.Message, messageWidth))
for _, f := range fields {
line.WriteByte(' ')
line.WriteString(f.key)
line.WriteByte('=')
line.WriteString(quote(f.value))
}
line.WriteByte('\n')
h.mu.Lock()
defer h.mu.Unlock()
_, err := io.WriteString(h.w, line.String())
return err
}
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
clone := *h
clone.attrs = slices.Clone(h.attrs)
for _, attr := range attrs {
clone.attrs = appendField(clone.attrs, h.groups, attr)
}
return &clone
}
func (h *consoleHandler) WithGroup(name string) slog.Handler {
clone := *h
clone.groups = append(slices.Clone(h.groups), name)
return &clone
}
func appendField(target []field, groups []string, attr slog.Attr) []field {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return target
}
if attr.Value.Kind() == slog.KindGroup {
for _, child := range attr.Value.Group() {
target = appendField(target, append(groups, attr.Key), child)
}
return target
}
key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
return append(target, field{key: key, value: attributeValue(attr.Value)})
}
// fieldRank puts the fields that identify *who and where* first, in the same order on
// every line, so scanning a column answers "which TV, which user" without reading.
// Anything unranked keeps the order the caller wrote it in, which is usually the order
// that reads best for that particular event.
var fieldRank = map[string]int{
"component": 1,
"user": 2,
"device": 3,
"client": 4,
"protocol": 5,
"method": 6,
"path": 7,
"status": 8,
"duration": 9,
// Constant per process, so it belongs at the end of the line rather than in front
// of the fields that differ between events.
"version": 900,
// An error is the reason for the line; keeping it last means it is never buried
// between routine fields.
"error": 1000,
}
func orderFields(fields []field) {
rank := func(f field) int {
if r, ok := fieldRank[f.key]; ok {
return r
}
return 100
}
slices.SortStableFunc(fields, func(a, b field) int { return rank(a) - rank(b) })
}
func pad(value string, width int) string {
if len(value) >= width {
return value
}
return value + strings.Repeat(" ", width-len(value))
}
// quote only intervenes when a bare value would be ambiguous. Quoting everything would
// double the punctuation on a line whose whole purpose is to be read.
func quote(value string) string {
if value == "" {
return `""`
}
if strings.ContainsAny(value, " \t\"=\n") {
return strconv.Quote(value)
}
return value
}
func attributeValue(value slog.Value) string {
switch value.Kind() {
case slog.KindDuration:
return value.Duration().String()
case slog.KindTime:
return value.Time().UTC().Format(time.RFC3339)
case slog.KindInt64:
return strconv.FormatInt(value.Int64(), 10)
default:
return value.String()
}
}
+48 -21
View File
@@ -28,10 +28,36 @@ func ParseLevel(value string) slog.Level {
}
}
// New returns a text logger suited to `docker compose logs`. Fields remain structured
// key=value pairs, but each event is one compact line rather than a JSON object.
// Format selects how a record is rendered. The wire shape of a log line is operational
// configuration, not a code decision: a person tailing `docker compose logs` and a
// collector shipping the same stream elsewhere want different things from it.
type Format int
const (
// FormatConsole is the default: one aligned, human-readable line per event.
FormatConsole Format = iota
// FormatLogfmt is slog's own `time=… level=… msg=…` text output.
FormatLogfmt
// FormatJSON is one object per line, for a log collector.
FormatJSON
)
// ParseFormat is forgiving for the same reason [ParseLevel] is — a typo in Docker
// configuration must not stop the gateway.
func ParseFormat(value string) Format {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "JSON":
return FormatJSON
case "LOGFMT", "TEXT":
return FormatLogfmt
default:
return FormatConsole
}
}
// New returns a logger suited to `docker compose logs`: one compact line per event.
func New(w io.Writer, level slog.Leveler) *slog.Logger {
logger, _ := NewBuffered(w, level, 0)
logger, _ := NewBuffered(w, level, 0, FormatConsole)
return logger
}
@@ -75,8 +101,11 @@ func ParseCapacity(value string, fallback int) int {
return capacity
}
// NewBuffered writes normal text logs and mirrors accepted records into a ring buffer.
func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *Buffer) {
// NewBuffered writes lines in the requested format and mirrors accepted records into a
// ring buffer, which is what the admin page's live log reads.
func NewBuffered(
w io.Writer, level slog.Leveler, capacity int, format Format,
) (*slog.Logger, *Buffer) {
options := &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
@@ -86,12 +115,20 @@ func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *
return attr
},
}
buffer := &Buffer{capacity: capacity}
text := slog.NewTextHandler(w, options)
if capacity <= 0 {
return slog.New(text), buffer
var written slog.Handler
switch format {
case FormatJSON:
written = slog.NewJSONHandler(w, options)
case FormatLogfmt:
written = slog.NewTextHandler(w, options)
default:
written = newConsoleHandler(w, level)
}
return slog.New(&captureHandler{next: text, buffer: buffer, level: level}), buffer
buffer := &Buffer{capacity: capacity}
if capacity <= 0 {
return slog.New(written), buffer
}
return slog.New(&captureHandler{next: written, buffer: buffer, level: level}), buffer
}
type captureHandler struct {
@@ -150,17 +187,7 @@ func addAttribute(target map[string]string, groups []string, attr slog.Attr) {
}
return
}
switch attr.Value.Kind() {
case slog.KindDuration:
target[key] = attr.Value.Duration().String()
case slog.KindTime:
target[key] = attr.Value.Time().UTC().Format(time.RFC3339Nano)
default:
target[key] = attr.Value.String()
if attr.Value.Kind() == slog.KindInt64 {
target[key] = strconv.FormatInt(attr.Value.Int64(), 10)
}
}
target[key] = attributeValue(attr.Value)
}
func (b *Buffer) append(event Event) {
+81 -9
View File
@@ -7,24 +7,96 @@ import (
"testing"
)
func TestNewWritesReadableStructuredText(t *testing.T) {
func TestConsoleLineLeadsWithTimeLevelAndMessage(t *testing.T) {
var output bytes.Buffer
New(&output, slog.LevelInfo).Info("server ready", "listen", ":8080")
New(&output, slog.LevelInfo).Info("gateway ready", "listen", ":8080")
line := strings.TrimRight(output.String(), "\n")
if strings.HasPrefix(line, "{") {
t.Fatalf("expected console output, got JSON: %s", line)
}
// The timestamp is a column, not a field: nothing labelled `time=` may appear in
// front of — or anywhere in — the readable part of the line.
if strings.Contains(line, "time=") || strings.Contains(line, "msg=") {
t.Fatalf("console line still carries slog's own keys: %s", line)
}
if !strings.Contains(line, "INFO") || !strings.Contains(line, "gateway ready") {
t.Fatalf("line is missing its level or message: %s", line)
}
if !strings.Contains(line, "listen=:8080") {
t.Fatalf("line is missing its fields: %s", line)
}
}
func TestConsoleLineOrdersIdentityFirstAndErrorLast(t *testing.T) {
var output bytes.Buffer
New(&output, slog.LevelInfo).Info("playback requested",
"error", "nope", "title", "Dune", "user", "matt", "component", "playback",
)
line := output.String()
if strings.HasPrefix(strings.TrimSpace(line), "{") {
t.Fatalf("expected text output, got JSON: %s", line)
}
for _, want := range []string{`level=INFO`, `msg="server ready"`, `listen=:8080`} {
if !strings.Contains(line, want) {
t.Errorf("output %q does not contain %q", line, want)
order := []string{"component=playback", "user=matt", "title=Dune", "error=nope"}
previous := -1
for _, want := range order {
at := strings.Index(line, want)
if at < 0 {
t.Fatalf("output %q does not contain %q", line, want)
}
if at < previous {
t.Fatalf("field %q is out of order in %q", want, line)
}
previous = at
}
}
func TestConsoleQuotesOnlyAmbiguousValues(t *testing.T) {
var output bytes.Buffer
New(&output, slog.LevelInfo).Info("signed in", "device", "Living Room", "user", "matt")
line := output.String()
if !strings.Contains(line, `device="Living Room"`) {
t.Errorf("a value containing a space was not quoted: %s", line)
}
if !strings.Contains(line, `user=matt`) {
t.Errorf("an unambiguous value was quoted: %s", line)
}
}
func TestParseFormat(t *testing.T) {
tests := map[string]Format{
"": FormatConsole,
"console": FormatConsole,
"nonsense": FormatConsole,
"json": FormatJSON,
" LOGFMT": FormatLogfmt,
"text": FormatLogfmt,
}
for input, want := range tests {
if got := ParseFormat(input); got != want {
t.Errorf("ParseFormat(%q) = %v, want %v", input, got, want)
}
}
}
func TestSelectedFormatIsWhatGetsWritten(t *testing.T) {
var output bytes.Buffer
logger, _ := NewBuffered(&output, slog.LevelInfo, 0, FormatJSON)
logger.Info("gateway ready")
if !strings.HasPrefix(strings.TrimSpace(output.String()), "{") {
t.Fatalf("JSON format did not produce JSON: %s", output.String())
}
output.Reset()
logger, _ = NewBuffered(&output, slog.LevelInfo, 0, FormatLogfmt)
logger.Info("gateway ready")
if !strings.Contains(output.String(), `msg="gateway ready"`) {
t.Fatalf("logfmt format did not produce logfmt: %s", output.String())
}
}
func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T) {
var output bytes.Buffer
logger, buffer := NewBuffered(&output, slog.LevelDebug, 3)
logger, buffer := NewBuffered(&output, slog.LevelDebug, 3, FormatConsole)
for i := 1; i <= 5; i++ {
logger.Info("request complete", "number", i)
}