Media build part 2 (unreleased): drive wiring in every app (R-1024/R-1037), old-install repair (R-1047), drive events notice + real restart now/later + stream check, file browser lets go of dead/removed drives and recreates on return (R-1038), safe removal keeps the drive (R-1044), Plex claim on the running server + probe accept (R-1028/R-1045)
gates / gates (push) Successful in 45s

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-15 09:54:49 +02:00
parent 78c894234c
commit f580d88a14
46 changed files with 3700 additions and 8 deletions
+12
View File
@@ -333,6 +333,8 @@ type Manager struct {
isMountPoint func(string) bool
// mediaDrivesFn replaces the enrolled-drive read of media_library.go (tests); nil in production.
mediaDrivesFn func() []MediaDrive
// onMediaUp runs after a media app's successful `compose up` (the drive wiring, R-1024); nil = nothing.
onMediaUp func(name string)
// mediaEnsureDirFn replaces appbackup.EnsureUserdataDir for the per-drive media folder (tests); nil in production.
mediaEnsureDirFn func(string) error
@@ -1685,9 +1687,19 @@ func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string)
}
m.logger.Printf("[DEBUG] Command completed: %s %s (took %.1fs)", m.composeCmd, strings.Join(args, " "), time.Since(start).Seconds())
// R-1024: a media app that came up (installed, started, restarted) gets the drives wired in (idempotent; the web
// side debounces). Never for a stack without the marker.
if len(args) > 0 && args[0] == "up" && m.onMediaUp != nil {
if meta := LoadMetadata(dir); meta.MediaLibrary != nil {
m.onMediaUp(filepath.Base(dir))
}
}
return stdout.String(), nil
}
// SetOnMediaUp registers the callback run after a media app's successful `compose up` (main.go: the wiring).
func (m *Manager) SetOnMediaUp(f func(name string)) { m.onMediaUp = f }
func (m *Manager) execCommand(name string, args ...string) (string, error) {
// execFn is the process-boundary seam (nil in production). It exists so R-51 can be proven
// through the REAL refreshStatusLocked path — docker ps → aggregateState → docker inspect —
@@ -86,6 +86,8 @@ func (m *Manager) mediaDrives() []MediaDrive {
seen[p] = true
d := MediaDrive{Path: p, Present: true}
switch {
case sp.Disconnected || sp.EjectedAt != "":
d.Present, d.Why = false, "removed"
case !isMount(p):
d.Present, d.Why = false, "not mounted"
case system.MountIsShutdown(p):
@@ -324,3 +326,14 @@ func (m *Manager) defaultMediaDrive() string {
}
return first
}
// PresentMediaDrives is the drives the media apps bind now (present, enrolled, not network, not removed).
func (m *Manager) PresentMediaDrives() []string {
var out []string
for _, d := range m.mediaDrives() {
if d.Present {
out = append(out, d.Path)
}
}
return out
}
+25 -1
View File
@@ -50,6 +50,9 @@ type SetupDoneProbe struct {
Done string `yaml:"done" json:"done"`
// DoneStatus (v0.282.0, R-715): a non-200 HTTP status that itself means "set up" (gramps-web answers 405).
DoneStatus int `yaml:"done_status,omitempty" json:"done_status,omitempty"`
// Accept (v0.313.0, R-1028/R-1045): the Accept header the probe sends — Plex answers /identity in XML unless asked
// for JSON (measured 2026-10-15). Empty = no header (unchanged for every other app).
Accept string `yaml:"accept,omitempty" json:"accept,omitempty"`
}
// Setup gate states.
@@ -380,12 +383,33 @@ var setupGateProbeFetch = func(url string) (int, []byte, error) {
return resp.StatusCode, b, err
}
// setupGateProbeFetchAccept is setupGateProbeFetch with an Accept header (a seam too).
var setupGateProbeFetchAccept = func(url, accept string) (int, []byte, error) {
c := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return 0, nil, err
}
req.Header.Set("Accept", accept)
resp, err := c.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, b, err
}
// probeOnce asks the app's own status once. v0.282.0 (R-715): `done_status:` — an app that answers a fixed non-200
// status once it is set up (gramps-web: 405 "Users already exist") — counts as done on that status alone; any other
// non-200 is "cannot read" (fail closed). A 200 is read as JSON: `field` is a dotted path whose parts may be list
// indexes (`setup.0.status` — ghost; `0.done` — home-assistant), compared as text with `done`.
func probeOnce(p *SetupDoneProbe) (done bool, got string, err error) {
status, body, err := setupGateProbeFetch(p.URL)
fetch := setupGateProbeFetch
if p.Accept != "" {
fetch = func(u string) (int, []byte, error) { return setupGateProbeFetchAccept(u, p.Accept) }
}
status, body, err := fetch(p.URL)
if err != nil {
return false, "", err
}
@@ -0,0 +1,42 @@
package stacks
import (
"net/http"
"net/http/httptest"
"testing"
)
// R-1028/R-1045: Plex's „claimed" can be probed only with Accept: application/json (it answers XML otherwise —
// measured 2026-10-15 on Tester 1). The probe with `accept:` reads the JSON field; without it the same server reads
// „not JSON" and the gate stays closed. COMPANION RED-PROOF: ignore p.Accept in probeOnce → the first case fails.
func TestSetupDoneProbe_AcceptJSON_Plex(t *testing.T) {
claimed := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := "0"
if claimed {
c = "1"
}
if r.Header.Get("Accept") == "application/json" {
v := "false"
if claimed {
v = "true"
}
_, _ = w.Write([]byte(`{"MediaContainer":{"size":0,"claimed":` + v + `}}`))
return
}
_, _ = w.Write([]byte(`<?xml version="1.0"?><MediaContainer size="0" claimed="` + c + `"></MediaContainer>`))
}))
defer srv.Close()
p := &SetupDoneProbe{URL: srv.URL + "/identity", Field: "MediaContainer.claimed", Done: "true", Accept: "application/json"}
if done, got, err := probeOnce(p); err != nil || done {
t.Fatalf("an unclaimed Plex must read NOT done: done=%v got=%q err=%v", done, got, err)
}
claimed = true
if done, got, err := probeOnce(p); err != nil || !done {
t.Fatalf("a claimed Plex must read done: done=%v got=%q err=%v", done, got, err)
}
p.Accept = ""
if done, got, _ := probeOnce(p); done || got != "not JSON" {
t.Fatalf("without the Accept header Plex answers XML: done=%v got=%q", done, got)
}
}