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
+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) {