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
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user