From f580d88a145978379a880714472508c33b616cab Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 15 Oct 2026 09:54:49 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 23 + controller/cmd/controller/main.go | 2 + controller/internal/i18n/locales/en.json | 26 +- controller/internal/i18n/locales/hu.json | 26 +- controller/internal/mediawire/docker.go | 86 ++ controller/internal/mediawire/mediawire.go | 1012 +++++++++++++++++ .../internal/mediawire/mediawire_test.go | 281 +++++ controller/internal/mediawire/repair_test.go | 98 ++ .../internal/settings/media_events_test.go | 56 + controller/internal/settings/settings.go | 115 ++ controller/internal/stacks/manager.go | 12 + controller/internal/stacks/media_library.go | 13 + controller/internal/stacks/setup_gate.go | 26 +- .../internal/stacks/setup_gate_accept_test.go | 42 + controller/internal/web/handlers.go | 31 + controller/internal/web/i18n_parity_test.go | 13 + controller/internal/web/intermediary.go | 6 +- controller/internal/web/media_events.go | 422 +++++++ controller/internal/web/media_events_test.go | 20 + controller/internal/web/plex_claim_test.go | 39 + controller/internal/web/server.go | 20 +- controller/internal/web/storage_handlers.go | 27 + .../internal/web/templates/app_info.html | 20 + .../internal/web/templates/dashboard.html | 1 + .../internal/web/templates/media_notice.html | 74 ++ .../internal/web/templates/storage.html | 1 + controller/internal/web/templates/style.css | 5 + .../i18n_parity/app_info_close_signup.html | 10 + .../app_info_close_signup_native.html | 10 + .../app_info_known_login_changed.html | 10 + .../i18n_parity/app_info_lost_files.html | 10 + .../i18n_parity/app_info_plex_claim.html | 629 ++++++++++ .../app_info_setup_gate_button.html | 10 + .../app_info_setup_gate_native.html | 10 + .../app_info_setup_gate_probe.html | 10 + .../i18n_parity/app_info_signup_closed.html | 10 + .../i18n_parity/app_info_signup_native.html | 10 + .../app_info_signup_native_open_failed.html | 10 + .../i18n_parity/dashboard_diskcrit.html | 68 ++ .../testdata/i18n_parity/dashboard_full.html | 68 ++ .../i18n_parity/dashboard_nobackup.html | 68 ++ .../i18n_parity/dashboard_sparse.html | 68 ++ .../i18n_parity/dashboard_tunnel_offline.html | 68 ++ .../testdata/i18n_parity/storage_empty.html | 68 ++ .../testdata/i18n_parity/storage_full.html | 68 ++ controller/scripts/i18n_go_keys.json | 6 +- 46 files changed, 3700 insertions(+), 8 deletions(-) create mode 100644 controller/internal/mediawire/docker.go create mode 100644 controller/internal/mediawire/mediawire.go create mode 100644 controller/internal/mediawire/mediawire_test.go create mode 100644 controller/internal/mediawire/repair_test.go create mode 100644 controller/internal/settings/media_events_test.go create mode 100644 controller/internal/stacks/setup_gate_accept_test.go create mode 100644 controller/internal/web/media_events.go create mode 100644 controller/internal/web/media_events_test.go create mode 100644 controller/internal/web/plex_claim_test.go create mode 100644 controller/internal/web/templates/media_notice.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_info_plex_claim.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 71fc3bb..a330387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,29 @@ Not released yet (bake → vouch → floor, `09` §3 decision 247). Design: felh in it, `media/torrents`, `media/transmission`, `import`. `TestProtectedHDDPaths_R1032_UserdataLayout` (red-proved). - **The wizard does not ask a media app for a drive (R-1031)** — HDD_PATH (the app's own config) goes on the default drive, else the first present one. `TestR1031_MediaAppIsNotAskedForADrive` (red-proved). +- **Part 2 — a new drive made ready in every app (R-1024, R-1037), package `internal/mediawire`.** After a media app + comes up (debounced 60 s) and after the media restart: per present drive the download folders; qBittorrent automatic + management + categories; Transmission's incomplete folder off; Sonarr/Radarr tag, root folder, the two download + clients, minimum free space; Prowlarr → Sonarr/Radarr once; Plex: the drive's folders added to the existing libraries, + a scan, „scan on change" on — with the server's own `PlexOnlineToken` read at use (`09` §3 decision 248); Jellyfin + with the API key named „felhom" read at use from Jellyfin's own database (no key → one household step, said on the + page). Keys read at use, never stored or logged; links never undone when an app stops. Idempotent: + `TestRun_SecondRunWritesNothing` (red-proved: the measured repeated qBittorrent writes). +- **Old Sonarr/Radarr installs repaired by themselves (R-1047)** — only when /media is no longer bound: same-path root + folder, `moveFiles:false` editor, rescan, old root removed, Radarr's collections too. `TestRepairOldRoot_K7` + (red-proved: moveFiles=true is caught). +- **Drive events (R-1023, R-1038, R-1044; decisions 238/239).** A drive added, removed or returned is recorded on the + drive; the storage page and the start page say which media apps must restart, with „Újraindítás most" (after a + Plex/Jellyfin stream check — a restart cuts a stream) or „Később" (03:30), and per drive „restarted since: yes/no". + The file browser skips a dead or safely removed drive and is RECREATED when a drive comes back (it held the dead mount). + A container still holding a dead drive (`shutdown` in its own mountinfo) is listed. **Safe removal** with media apps: + pause only that drive's torrents, recreate the media apps and the file browser without it, unmount — and keep the + drive registered; its return needs no household step and its torrents resume after the restart. + `TestMediaEvents_*`, `TestNextMediaLater` (red-proved: the paused list kept through the return). +- **Plex without the 4-minute race (R-1028, R-1045)** — the app page of a closed Plex gate asks for the claim code; the + box claims the RUNNING server (`POST /myplex/claim?token=`), never logs the code, and the gate opens only when Plex says + claimed. The setup probe gains `accept:` (Plex answers XML without it). `TestSetupDoneProbe_AcceptJSON_Plex`, + `TestPlexClaimBox_RendersPerBranch` (both red-proved). ## Unreleased (2026-10-15) — the shared rule file; no code change diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index e4039a9..8e1bf5f 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1968,6 +1968,8 @@ func main() { // Migration done-hook: a decommission-initiated migration finalizes the source decommission on // success (soft-mark + agent). Wire it before RecoverMigration so a resumed one still finalizes. stackMgr.SetMigrationDoneHook(webServer.OnMigrationDone) + // R-1024 (v0.313.0): a media app that comes up gets every present drive wired in (debounced, idempotent). + stackMgr.SetOnMediaUp(webServer.RunMediaWiringSoon) // R-536: „Alkalmazás telepítve" is sent when the async deploy ACTUALLY finishes, and a deploy // that ends badly now says so instead of being silence. The accept-time event is // `app_deploy_started`, emitted by the API router beside its 202. diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 4c1dcd7..2603439 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2693,5 +2693,29 @@ "app_info.lost_files_taken": "taken:", "app_info.lost_files_hint": "If your phone still has them, upload them again. Check now, before your phone frees their space.", "app_info.lost_files_dismiss_btn": "OK, I have read this", - "err.lost_files.nothing_to_dismiss": "There is no note about lost files for this app." + "err.lost_files.nothing_to_dismiss": "There is no note about lost files for this app.", + "media.notice_title": "The media apps must restart", + "media.notice_body": "A drive changed. The media apps see the change only after a restart.", + "media.change_added": "added", + "media.change_removed": "removed", + "media.change_returned": "came back", + "media.restarted_yes": "apps restarted since then: yes", + "media.restarted_no": "apps restarted since then: no", + "media.apps": "Apps:", + "media.stale": "Still holds a dead drive:", + "media.restart_now": "Restart now", + "media.restart_later": "Later (tonight 3:30)", + "media.later_at": "Scheduled:", + "media.streams_playing": "Something is playing now. A restart cuts it. Restart anyway?", + "media.restart_done": "Restarted. The drives are connected within a minute or two.", + "media.wiring_title": "Drives connected to the apps", + "media.wire_now": "Connect now", + "media.state_household": "needs one step from you", + "media.jellyfin_key_hint": "Jellyfin: Dashboard → API Keys → +, key name: felhom. Then press „Connect now”.", + "media.error": "Error:", + "app_info.plex_claim_intro": "Open https://plex.tv/claim with your Plex account, copy the code and paste it here. The code lives 4 minutes, but Plex already runs, so there is no rush with the install.", + "app_info.plex_claim_btn": "Connect Plex", + "err.plex_claim.bad_code": "This is not a Plex code. A code starts with: claim-", + "err.plex_claim.refused": "Plex did not accept the code. It may have run out: get a new one at plex.tv/claim.", + "err.plex_claim.not_claimed": "Plex does not report the connection yet. Try again in a minute." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index cf468d7..d11ae48 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2681,5 +2681,29 @@ "app_info.lost_files_taken": "készült:", "app_info.lost_files_hint": "Ha a telefonodon még megvannak, töltsd fel őket újra. Nézd meg most, mielőtt a telefon felszabadítja a helyüket.", "app_info.lost_files_dismiss_btn": "Rendben, elolvastam", - "err.lost_files.nothing_to_dismiss": "Ehhez az alkalmazáshoz nincs elveszett fájlokról szóló értesítés." + "err.lost_files.nothing_to_dismiss": "Ehhez az alkalmazáshoz nincs elveszett fájlokról szóló értesítés.", + "media.notice_title": "A médiaalkalmazásoknak újra kell indulniuk", + "media.notice_body": "Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.", + "media.change_added": "hozzáadva", + "media.change_removed": "eltávolítva", + "media.change_returned": "visszatért", + "media.restarted_yes": "az alkalmazások azóta újraindultak: igen", + "media.restarted_no": "az alkalmazások azóta újraindultak: nem", + "media.apps": "Érintett alkalmazások:", + "media.stale": "Még egy halott meghajtót tart:", + "media.restart_now": "Újraindítás most", + "media.restart_later": "Később (ma éjjel 3:30)", + "media.later_at": "Ütemezve:", + "media.streams_playing": "Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?", + "media.restart_done": "Újraindítva. A meghajtók bekötése egy-két perc múlva kész.", + "media.wiring_title": "Meghajtók bekötése az alkalmazásokba", + "media.wire_now": "Bekötés most", + "media.state_household": "egy lépés kell tőled", + "media.jellyfin_key_hint": "Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.", + "media.error": "Hiba:", + "app_info.plex_claim_intro": "Nyisd meg a https://plex.tv/claim oldalt a Plex-fiókoddal, másold ki a kódot, és illeszd be ide. A kód 4 percig érvényes, de a Plex már fut, így nem kell sietned a telepítéssel.", + "app_info.plex_claim_btn": "Plex összekapcsolása", + "err.plex_claim.bad_code": "Ez nem Plex-kód. A kód így kezdődik: claim-", + "err.plex_claim.refused": "A Plex nem fogadta el a kódot. Lehet, hogy lejárt: kérj újat a plex.tv/claim oldalon.", + "err.plex_claim.not_claimed": "A Plex még nem jelzi, hogy össze van kapcsolva. Próbáld újra egy perc múlva." } diff --git a/controller/internal/mediawire/docker.go b/controller/internal/mediawire/docker.go new file mode 100644 index 0000000..b571f11 --- /dev/null +++ b/controller/internal/mediawire/docker.go @@ -0,0 +1,86 @@ +package mediawire + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + _ "modernc.org/sqlite" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" +) + +// DockerBox is the production Box: the docker CLI on this guest. +type DockerBox struct{} + +func (DockerBox) Running(c string) bool { + out, err := dockerexec.Command("docker", "inspect", "-f", "{{.State.Running}}", c).Output() + return err == nil && strings.TrimSpace(string(out)) == "true" +} + +func (DockerBox) Env(c, name string) (string, error) { + out, err := dockerexec.Command("docker", "exec", c, "printenv", name).Output() + if err != nil { + return "", fmt.Errorf("%s: reading %s: %w", c, name, err) + } + return strings.TrimRight(string(out), "\n"), nil +} + +func (DockerBox) ReadFile(c, path string) ([]byte, error) { + out, err := dockerexec.Command("docker", "exec", c, "cat", path).Output() + if err != nil { + return nil, fmt.Errorf("%s: reading %s: %w", c, path, err) + } + return out, nil +} + +func (DockerBox) EnsureDir(p string) error { return appbackup.EnsureUserdataDir(p) } + +func (DockerBox) Mounts(c string) map[string]string { + out, err := dockerexec.Command("docker", "inspect", "-f", "{{json .Mounts}}", c).Output() + res := map[string]string{} + if err != nil { + return res + } + var ms []struct { + Source string `json:"Source"` + Destination string `json:"Destination"` + } + if json.Unmarshal(out, &ms) == nil { + for _, m := range ms { + res[filepath.Clean(m.Destination)] = m.Source + } + } + return res +} + +// JellyfinKey copies Jellyfin's database (with its -wal/-shm, R-286's lesson) into a 0700 temp dir, reads the API key +// with this name read-only, and removes the copy at once. The key is returned to the caller only. +func (DockerBox) JellyfinKey(name string) (string, error) { + dir, err := os.MkdirTemp("", "felhom-jf-") + if err != nil { + return "", err + } + defer os.RemoveAll(dir) + for _, f := range []string{"jellyfin.db", "jellyfin.db-wal", "jellyfin.db-shm"} { + err := dockerexec.Command("docker", "cp", "jellyfin:/config/data/"+f, filepath.Join(dir, f)).Run() + if err != nil && f == "jellyfin.db" { + return "", fmt.Errorf("copying Jellyfin's database: %w", err) + } + } + db, err := sql.Open("sqlite", "file:"+filepath.Join(dir, "jellyfin.db")) + if err != nil { + return "", err + } + defer db.Close() + var key string + err = db.QueryRow(`SELECT AccessToken FROM ApiKeys WHERE Name = ? ORDER BY DateCreated DESC LIMIT 1`, name).Scan(&key) + if err == sql.ErrNoRows { + return "", nil + } + return key, err +} diff --git a/controller/internal/mediawire/mediawire.go b/controller/internal/mediawire/mediawire.go new file mode 100644 index 0000000..909000e --- /dev/null +++ b/controller/internal/mediawire/mediawire.go @@ -0,0 +1,1012 @@ +// Package mediawire makes every PRESENT drive ready inside the media apps (v0.313.0, R-1024, R-1037; felhom.eu +// `architecture/15-media-library.md` §6 and §10 „added"). It is the Go port of the measured calls in +// `audits/media-all-drives-2026-10-14/tools/wire.py` and `audits/media-drive-events-2026-10-14/tools/wire.py`. +// +// Per drive d (M = /userdata/media, the SAME path in every container): +// +// folders : M/torrents/{tv,movies}, M/transmission/{tv,movies} +// qBittorrent : automatic torrent management ON; categories tv-, movies- (save path M/torrents/, +// incomplete path M/torrents/.temp) — written only when they differ (K2: run 2 re-sent equal edits) +// Transmission : incomplete folder OFF (belt; the template's entrypoint writes it into settings.json at start) +// Sonarr/Radarr: tag ; root folder M/tv | M/movies; „qBittorrent " (category) and „Transmission " +// (directory, category CLEARED); minimum free space when importing +// Prowlarr : one application each for Sonarr and Radarr, „Full Sync" (once) +// Plex : the drive's folders added to the EXISTING TV/film libraries, a scan, „scan when a change is +// detected" ON — with the server's own PlexOnlineToken read from Preferences.xml AT USE (`09` §3 +// decision 248); never stored, never logged, sent only to http://plex:32400 +// Jellyfin : the drive's folders added, a scan — with the API key named „felhom" read from Jellyfin's own +// database AT USE. No such key → one household step, said on the app's page. +// +// IDEMPOTENT: every write is preceded by a read and skipped when the state is already right; a second run writes +// nothing (TestRun_SecondRunWritesNothing). Links are NEVER undone when an app stops (unlike the OnlyOffice links of +// integrations/lifecycle.go): a stopped download client keeps its entries. +// +// SECRETS: every key and password is read at use and passed only in request headers/bodies to the app's own +// container. Nothing is written to disk except a transient copy of Jellyfin's database in a 0700 temp dir, removed at +// once. Log lines name keys, never values. +package mediawire + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// MinFreeMB is Sonarr/Radarr's „minimum free space when importing". +const MinFreeMB = 10240 + +// Box is everything the wiring touches outside HTTP — a seam: tests fake it, production is DockerBox. +type Box interface { + Running(container string) bool + Env(container, name string) (string, error) // `docker exec printenv ` + ReadFile(container, path string) ([]byte, error) // `docker exec cat ` + JellyfinKey(name string) (string, error) // the API key with this name from Jellyfin's own DB + EnsureDir(path string) error // the userdata convention (2775, gid 1000) + Mounts(container string) map[string]string // container target → host source +} + +// Drive is one present drive. +type Drive struct { + Path string // the drive's namespace root, e.g. /mnt/felhom-drives/media_b +} + +// Tag is the Sonarr/Radarr tag and qBittorrent category suffix for the drive: its folder name, lowercased, every +// character outside [a-z0-9] turned into '-' (Sonarr refuses others). +func (d Drive) Tag() string { + t := strings.ToLower(filepath.Base(d.Path)) + t = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(t, "-") + return strings.Trim(t, "-") +} + +// Media is the drive's shared library root. +func (d Drive) Media() string { return filepath.Join(d.Path, "userdata", "media") } + +// Result is one app's outcome of a run. +type Result struct { + App string `json:"app"` + State string `json:"state"` // done | unchanged | skipped | household | error + Wrote []string `json:"wrote,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// Wirer runs the wiring. URL maps a container name to its base URL (tests point it at httptest servers). +type Wirer struct { + Box Box + Logger *log.Logger + HTTP *http.Client + URL func(container string, port int) string + mu sync.Mutex +} + +// New is the production Wirer: apps by container name on the docker network. +func New(box Box, logger *log.Logger) *Wirer { + return &Wirer{Box: box, Logger: logger, HTTP: &http.Client{Timeout: 60 * time.Second}, + URL: func(c string, port int) string { return fmt.Sprintf("http://%s:%d", c, port) }} +} + +// Run wires every present drive into every installed media app. Single-flight: a second caller waits. +func (w *Wirer) Run(drives []Drive) []Result { + w.mu.Lock() + defer w.mu.Unlock() + start := time.Now() + sort.Slice(drives, func(i, j int) bool { return drives[i].Path < drives[j].Path }) + var out []Result + out = append(out, w.folders(drives)) + qbPW, trPW := "", "" + if w.Box.Running("qbittorrent") { + qbPW, _ = w.Box.Env("qbittorrent", "QBT_ADMIN_PASSWORD") + out = append(out, w.qbittorrent(drives, qbPW)) + } + if w.Box.Running("transmission") { + trPW, _ = w.Box.Env("transmission", "PASS") + out = append(out, w.transmission(trPW)) + } + for _, a := range []struct { + app string + port int + kind string + rootKind string + }{{"sonarr", 8989, "tv", "tv"}, {"radarr", 7878, "movie", "movies"}} { + if w.Box.Running(a.app) { + out = append(out, w.arr(a.app, a.port, a.kind, a.rootKind, drives, qbPW, trPW)) + } + } + if w.Box.Running("prowlarr") { + out = append(out, w.prowlarr()) + } + if w.Box.Running("plex") { + out = append(out, w.plex(drives)) + } + if w.Box.Running("jellyfin") { + out = append(out, w.jellyfin(drives)) + } + var parts []string + for _, r := range out { + parts = append(parts, fmt.Sprintf("%s=%s(%d)", r.App, r.State, len(r.Wrote))) + } + w.Logger.Printf("[INFO] [mediawire] %d drive(s) wired in %s: %s", len(drives), time.Since(start).Round(time.Millisecond), strings.Join(parts, " ")) + return out +} + +func (w *Wirer) folders(drives []Drive) Result { + r := Result{App: "folders"} + for _, d := range drives { + for _, sub := range []string{"torrents/tv", "torrents/movies", "transmission/tv", "transmission/movies"} { + if err := w.Box.EnsureDir(filepath.Join(d.Media(), filepath.FromSlash(sub))); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + } + } + r.State = "done" + return r +} + +// ── HTTP helpers ───────────────────────────────────────────────────────────────────────────────────────────────── + +func (w *Wirer) do(method, u string, hdr map[string]string, body io.Reader, ctype string) (int, []byte, http.Header, error) { + req, err := http.NewRequest(method, u, body) + if err != nil { + return 0, nil, nil, err + } + for k, v := range hdr { + if strings.EqualFold(k, "Host") { + req.Host = v + continue + } + req.Header.Set(k, v) + } + if ctype != "" { + req.Header.Set("Content-Type", ctype) + } + resp, err := w.HTTP.Do(req) + if err != nil { + return 0, nil, nil, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + return resp.StatusCode, b, resp.Header, nil +} + +func (w *Wirer) jsonCall(method, u string, hdr map[string]string, in, out interface{}) (int, error) { + var body io.Reader + ct := "" + if in != nil { + b, _ := json.Marshal(in) + body, ct = bytes.NewReader(b), "application/json" + } + code, b, _, err := w.do(method, u, hdr, body, ct) + if err != nil { + return code, err + } + if out != nil && len(b) > 0 { + if err := json.Unmarshal(b, out); err != nil && code < 300 { + return code, fmt.Errorf("%s %s: answer not JSON", method, redactURL(u)) + } + } + if code >= 300 { + return code, fmt.Errorf("%s %s: HTTP %d", method, redactURL(u), code) + } + return code, nil +} + +func redactURL(u string) string { + if i := strings.Index(u, "?"); i >= 0 { + return u[:i] + "?…" + } + return u +} + +// ── qBittorrent ───────────────────────────────────────────────────────────────────────────────────────────────── + +func (w *Wirer) qbittorrent(drives []Drive, pw string) Result { + r := Result{App: "qbittorrent"} + base := w.URL("qbittorrent", 8080) + form := url.Values{"username": {"admin"}, "password": {pw}} + code, _, hd, err := w.do("POST", base+"/api/v2/auth/login", map[string]string{"Referer": base}, strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") + m := regexp.MustCompile(`((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)`).FindStringSubmatch(strings.Join(hd.Values("Set-Cookie"), "; ")) + if err != nil || code != 200 || m == nil { + r.State, r.Detail = "error", fmt.Sprintf("login: HTTP %d %v", code, err) + return r + } + H := map[string]string{"Referer": base, "Cookie": m[1] + "=" + m[2]} + var prefs map[string]interface{} + if _, err := w.jsonCall("GET", base+"/api/v2/app/preferences", H, nil, &prefs); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + if prefs["auto_tmm_enabled"] != true { + f := url.Values{"json": {`{"auto_tmm_enabled":true}`}} + if c, _, _, err := w.do("POST", base+"/api/v2/app/setPreferences", H, strings.NewReader(f.Encode()), "application/x-www-form-urlencoded"); err != nil || c != 200 { + r.State, r.Detail = "error", fmt.Sprintf("setPreferences: HTTP %d %v", c, err) + return r + } + r.Wrote = append(r.Wrote, "auto_tmm_enabled=true") + } + var cats map[string]map[string]interface{} + if _, err := w.jsonCall("GET", base+"/api/v2/torrents/categories", H, nil, &cats); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + for _, d := range drives { + for _, kind := range []string{"tv", "movies"} { + name := kind + "-" + d.Tag() + save := filepath.Join(d.Media(), "torrents", kind) + dl := filepath.Join(d.Media(), "torrents", ".temp") + if c, ok := cats[name]; ok && fmt.Sprint(c["savePath"]) == save && (fmt.Sprint(c["download_path"]) == dl || fmt.Sprint(c["downloadPath"]) == dl) { + continue + } + ep := "createCategory" + if _, ok := cats[name]; ok { + ep = "editCategory" + } + f := url.Values{"category": {name}, "savePath": {save}, "downloadPathEnabled": {"true"}, "downloadPath": {dl}} + if c, _, _, err := w.do("POST", base+"/api/v2/torrents/"+ep, H, strings.NewReader(f.Encode()), "application/x-www-form-urlencoded"); err != nil || c != 200 { + r.State, r.Detail = "error", fmt.Sprintf("%s %s: HTTP %d %v", ep, name, c, err) + return r + } + r.Wrote = append(r.Wrote, ep+" "+name) + } + } + r.State = stateOf(r) + return r +} + +func stateOf(r Result) string { + if len(r.Wrote) == 0 { + return "unchanged" + } + return "done" +} + +// ── Transmission ──────────────────────────────────────────────────────────────────────────────────────────────── + +func (w *Wirer) transmission(pw string) Result { + r := Result{App: "transmission"} + u := w.URL("transmission", 9091) + "/transmission/rpc" + H := map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:"+pw)), "Host": "transmission:9091"} + rpc := func(method string, args map[string]interface{}, out interface{}) error { + body, _ := json.Marshal(map[string]interface{}{"method": method, "arguments": args}) + for i := 0; i < 2; i++ { + c, b, hd, err := w.do("POST", u, H, bytes.NewReader(body), "application/json") + if err != nil { + return err + } + if c == 409 { + H["X-Transmission-Session-Id"] = hd.Get("X-Transmission-Session-Id") + continue + } + if c != 200 { + return fmt.Errorf("%s: HTTP %d", method, c) + } + if out != nil { + return json.Unmarshal(b, out) + } + return nil + } + return fmt.Errorf("%s: no session id", method) + } + var got struct { + Arguments map[string]interface{} `json:"arguments"` + } + if err := rpc("session-get", map[string]interface{}{"fields": []string{"incomplete-dir-enabled"}}, &got); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + if got.Arguments["incomplete-dir-enabled"] == true { + if err := rpc("session-set", map[string]interface{}{"incomplete-dir-enabled": false}, nil); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + r.Wrote = append(r.Wrote, "incomplete-dir-enabled=false") + } + r.State = stateOf(r) + return r +} + +// ── Sonarr / Radarr ───────────────────────────────────────────────────────────────────────────────────────────── + +var apiKeyRE = regexp.MustCompile(`([^<]+)`) + +func (w *Wirer) arrKey(app string) (string, error) { + b, err := w.Box.ReadFile(app, "/config/config.xml") + if err != nil { + return "", err + } + m := apiKeyRE.FindSubmatch(b) + if m == nil { + return "", fmt.Errorf("no in %s's config.xml", app) + } + return string(m[1]), nil +} + +type arrField struct { + Name string `json:"name"` + Value interface{} `json:"value,omitempty"` +} + +func (w *Wirer) arr(app string, port int, kind, rootKind string, drives []Drive, qbPW, trPW string) Result { + r := Result{App: app} + key, err := w.arrKey(app) + if err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + base := w.URL(app, port) + "/api/v3" + H := map[string]string{"X-Api-Key": key, "Host": app} + fail := func(err error) Result { r.State, r.Detail = "error", err.Error(); return r } + + // R-1047 (K7): an install from before app-catalog bc54c01 keeps its library at /media/; the new template binds + // the same files at the same path (/userdata/media). Repair once, through the app's own API, no file moved. + if err := w.repairOldRoot(app, base, H, rootKind, &r); err != nil { + return fail(err) + } + + var tags []struct { + ID int `json:"id"` + Label string `json:"label"` + } + if _, err := w.jsonCall("GET", base+"/tag", H, nil, &tags); err != nil { + return fail(err) + } + tagID := map[string]int{} + for _, t := range tags { + tagID[t.Label] = t.ID + } + for _, d := range drives { + if _, ok := tagID[d.Tag()]; ok { + continue + } + var t struct { + ID int `json:"id"` + } + if _, err := w.jsonCall("POST", base+"/tag", H, map[string]string{"label": d.Tag()}, &t); err != nil { + return fail(err) + } + tagID[d.Tag()] = t.ID + r.Wrote = append(r.Wrote, "tag "+d.Tag()) + } + + var roots []struct { + Path string `json:"path"` + } + if _, err := w.jsonCall("GET", base+"/rootfolder", H, nil, &roots); err != nil { + return fail(err) + } + have := map[string]bool{} + for _, x := range roots { + have[strings.TrimRight(x.Path, "/")] = true + } + for _, d := range drives { + p := filepath.Join(d.Media(), rootKind) + if have[p] { + continue + } + if _, err := w.jsonCall("POST", base+"/rootfolder", H, map[string]string{"path": p}, nil); err != nil { + return fail(err) + } + r.Wrote = append(r.Wrote, "rootfolder "+p) + } + + var clients []struct { + Name string `json:"name"` + } + if _, err := w.jsonCall("GET", base+"/downloadclient", H, nil, &clients); err != nil { + return fail(err) + } + names := map[string]bool{} + for _, c := range clients { + names[c.Name] = true + } + var schema []map[string]interface{} + schemaRead := false + for _, d := range drives { + type want struct { + impl, name string + vals map[string]interface{} + present bool + } + ws := []want{ + {"QBittorrent", "qBittorrent " + d.Tag(), map[string]interface{}{"host": "qbittorrent", "port": 8080, "username": "admin", "password": qbPW, + kind + "Category": map[string]string{"tv": "tv", "movie": "movies"}[kind] + "-" + d.Tag()}, w.Box.Running("qbittorrent")}, + {"Transmission", "Transmission " + d.Tag(), map[string]interface{}{"host": "transmission", "port": 9091, "username": "admin", "password": trPW, + "urlBase": "/transmission/", kind + "Category": "", kind + "Directory": filepath.Join(d.Media(), "transmission", rootKind)}, w.Box.Running("transmission")}, + } + for _, x := range ws { + if !x.present || names[x.name] { + continue + } + if !schemaRead { + if _, err := w.jsonCall("GET", base+"/downloadclient/schema", H, nil, &schema); err != nil { + return fail(err) + } + schemaRead = true + } + var body map[string]interface{} + for _, s := range schema { + if s["implementation"] == x.impl { + b, _ := json.Marshal(s) + _ = json.Unmarshal(b, &body) + } + } + if body == nil { + return fail(fmt.Errorf("no %s download client in %s's schema", x.impl, app)) + } + body["name"], body["enable"], body["tags"], body["priority"] = x.name, true, []int{tagID[d.Tag()]}, 1 + if fs, ok := body["fields"].([]interface{}); ok { + for _, f := range fs { + fm, _ := f.(map[string]interface{}) + if v, ok := x.vals[fmt.Sprint(fm["name"])]; ok { + fm["value"] = v + } + } + } + if _, err := w.jsonCall("POST", base+"/downloadclient", H, body, nil); err != nil { + return fail(fmt.Errorf("%s: %v (the app's own connection test may have refused it)", x.name, err)) + } + r.Wrote = append(r.Wrote, "downloadclient "+x.name) + } + } + + var mm map[string]interface{} + if _, err := w.jsonCall("GET", base+"/config/mediamanagement", H, nil, &mm); err != nil { + return fail(err) + } + if v, _ := mm["minimumFreeSpaceWhenImporting"].(float64); int(v) < MinFreeMB { + mm["minimumFreeSpaceWhenImporting"] = MinFreeMB + if _, err := w.jsonCall("PUT", fmt.Sprintf("%s/config/mediamanagement/%v", base, mm["id"]), H, mm, nil); err != nil { + return fail(err) + } + r.Wrote = append(r.Wrote, fmt.Sprintf("minimumFreeSpaceWhenImporting=%d", MinFreeMB)) + } + r.State = stateOf(r) + return r +} + +// ── Prowlarr ──────────────────────────────────────────────────────────────────────────────────────────────────── + +func (w *Wirer) prowlarr() Result { + r := Result{App: "prowlarr"} + key, err := w.arrKey("prowlarr") + if err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + base := w.URL("prowlarr", 9696) + "/api/v1" + H := map[string]string{"X-Api-Key": key, "Host": "prowlarr"} + var apps []struct { + Name string `json:"name"` + } + if _, err := w.jsonCall("GET", base+"/applications", H, nil, &apps); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + have := map[string]bool{} + for _, a := range apps { + have[a.Name] = true + } + var schema []map[string]interface{} + for _, x := range []struct { + impl string + port int + }{{"Sonarr", 8989}, {"Radarr", 7878}} { + app := strings.ToLower(x.impl) + if have[x.impl] || !w.Box.Running(app) { + continue + } + appKey, err := w.arrKey(app) + if err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + if schema == nil { + if _, err := w.jsonCall("GET", base+"/applications/schema", H, nil, &schema); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + } + var body map[string]interface{} + for _, s := range schema { + if s["implementation"] == x.impl { + b, _ := json.Marshal(s) + _ = json.Unmarshal(b, &body) + } + } + if body == nil { + r.State, r.Detail = "error", "no "+x.impl+" in Prowlarr's schema" + return r + } + vals := map[string]interface{}{"prowlarrUrl": "http://prowlarr:9696", "baseUrl": fmt.Sprintf("http://%s:%d", app, x.port), "apiKey": appKey} + if fs, ok := body["fields"].([]interface{}); ok { + for _, f := range fs { + fm, _ := f.(map[string]interface{}) + if v, ok := vals[fmt.Sprint(fm["name"])]; ok { + fm["value"] = v + } + } + } + body["name"], body["syncLevel"], body["tags"] = x.impl, "fullSync", []int{} + if _, err := w.jsonCall("POST", base+"/applications", H, body, nil); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + r.Wrote = append(r.Wrote, "application "+x.impl) + } + r.State = stateOf(r) + return r +} + +// ── Plex ──────────────────────────────────────────────────────────────────────────────────────────────────────── + +// PlexPrefsPath is the server's own preferences file inside the container. +const PlexPrefsPath = "/config/Library/Application Support/Plex Media Server/Preferences.xml" + +var plexTokenRE = regexp.MustCompile(`PlexOnlineToken="([^"]+)"`) + +// PlexToken reads the server's own PlexOnlineToken at use (decision 248). "" when the server is not claimed. +func PlexToken(box Box) string { + b, err := box.ReadFile("plex", PlexPrefsPath) + if err != nil { + return "" + } + if m := plexTokenRE.FindSubmatch(b); m != nil { + return string(m[1]) + } + return "" +} + +// coveredByMedia: the drive whose media folder the player's template binds at /media — its library folders are already +// in the library as /media/ (the one-drive layout), so they are not added a second time at the same-path name. +func (w *Wirer) coveredByMedia(container string) string { + src := w.Box.Mounts(container)["/media"] + if src == "" { + return "" + } + return filepath.Clean(src) +} + +func (w *Wirer) plex(drives []Drive) Result { + r := Result{App: "plex"} + tok := PlexToken(w.Box) + if tok == "" { + r.State, r.Detail = "household", "Plex is not claimed yet — claim it on its page first" + return r + } + base := w.URL("plex", 32400) + H := map[string]string{"Accept": "application/json", "X-Plex-Token": tok} + var secs struct { + MediaContainer struct { + Directory []struct { + Key string `json:"key"` + Type string `json:"type"` + Title string `json:"title"` + Agent string `json:"agent"` + Scanner string `json:"scanner"` + Language string `json:"language"` + Location []struct { + Path string `json:"path"` + } `json:"Location"` + } `json:"Directory"` + } `json:"MediaContainer"` + } + if _, err := w.jsonCall("GET", base+"/library/sections", H, nil, &secs); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + sub := map[string]string{"show": "tv", "movie": "movies"} + coveredPlex := w.coveredByMedia("plex") + for _, s := range secs.MediaContainer.Directory { + k, ok := sub[s.Type] + if !ok { + continue + } + var have []string + haveSet := map[string]bool{} + for _, l := range s.Location { + have = append(have, l.Path) + haveSet[strings.TrimRight(l.Path, "/")] = true + } + var missing []string + for _, d := range drives { + p := filepath.Join(d.Media(), k) + if d.Media() == coveredPlex && haveSet["/media/"+k] { + continue + } + if !haveSet[p] { + missing = append(missing, p) + } + } + if len(missing) == 0 { + continue + } + q := url.Values{"name": {s.Title}, "type": {s.Type}, "agent": {s.Agent}, "scanner": {s.Scanner}, "language": {s.Language}} + for _, p := range append(have, missing...) { + q.Add("location", p) + } + if c, _, _, err := w.do("PUT", base+"/library/sections/"+s.Key+"?"+q.Encode(), H, nil, ""); err != nil || c >= 300 { + r.State, r.Detail = "error", fmt.Sprintf("library %s: HTTP %d %v", s.Title, c, err) + return r + } + _, _, _, _ = w.do("GET", base+"/library/sections/"+s.Key+"/refresh", H, nil, "") + r.Wrote = append(r.Wrote, fmt.Sprintf("library %s +%d folder(s), scan", s.Title, len(missing))) + } + var prefs struct { + MediaContainer struct { + Setting []struct { + ID string `json:"id"` + Value interface{} `json:"value"` + } `json:"Setting"` + } `json:"MediaContainer"` + } + if _, err := w.jsonCall("GET", base+"/:/prefs", H, nil, &prefs); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + for _, id := range []string{"FSEventLibraryUpdatesEnabled", "FSEventLibraryPartialScanEnabled"} { + on := false + for _, s := range prefs.MediaContainer.Setting { + if s.ID == id && s.Value == true { + on = true + } + } + if on { + continue + } + if c, _, _, err := w.do("PUT", base+"/:/prefs?"+id+"=1", H, nil, ""); err != nil || c >= 300 { + r.State, r.Detail = "error", fmt.Sprintf("pref %s: HTTP %d %v", id, c, err) + return r + } + r.Wrote = append(r.Wrote, id+"=1") + } + r.State = stateOf(r) + return r +} + +// ── Jellyfin ──────────────────────────────────────────────────────────────────────────────────────────────────── + +// JellyfinKeyName is the API key the box uses: made by the household once (Jellyfin → Dashboard → API Keys → +). +const JellyfinKeyName = "felhom" + +func (w *Wirer) jellyfin(drives []Drive) Result { + r := Result{App: "jellyfin"} + key, err := w.Box.JellyfinKey(JellyfinKeyName) + if err != nil || key == "" { + r.State, r.Detail = "household", "no Jellyfin API key named „felhom” — one step on the Jellyfin page" + return r + } + base := w.URL("jellyfin", 8096) + H := map[string]string{"X-Emby-Token": key} + var vf []struct { + Name string `json:"Name"` + CollectionType string `json:"CollectionType"` + Locations []string `json:"Locations"` + } + if _, err := w.jsonCall("GET", base+"/Library/VirtualFolders", H, nil, &vf); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + sub := map[string]string{"tvshows": "tv", "movies": "movies"} + coveredJF := w.coveredByMedia("jellyfin") + for _, v := range vf { + k, ok := sub[v.CollectionType] + if !ok { + continue + } + have := map[string]bool{} + for _, l := range v.Locations { + have[strings.TrimRight(l, "/")] = true + } + for _, d := range drives { + p := filepath.Join(d.Media(), k) + if have[p] || (d.Media() == coveredJF && have["/media/"+k]) { + continue + } + body := map[string]interface{}{"Name": v.Name, "PathInfo": map[string]string{"Path": p}} + if _, err := w.jsonCall("POST", base+"/Library/VirtualFolders/Paths?refreshLibrary=false", H, body, nil); err != nil { + r.State, r.Detail = "error", err.Error() + return r + } + r.Wrote = append(r.Wrote, "library "+v.Name+" + "+p) + } + } + if len(r.Wrote) > 0 { + _, _ = w.jsonCall("POST", base+"/Library/Refresh", H, nil, nil) + r.Wrote = append(r.Wrote, "scan") + } + r.State = stateOf(r) + return r +} + +// ── Safe removal: pause / resume only one drive's torrents (R-1044, `15` §10 „safe removal", K3-2) ───────────────── + +// PauseOnDrive stops every torrent whose save folder is on the drive (qBittorrent by save_path, Transmission by +// downloadDir) and returns them as "qbit:" / "trans:" so the return resumes exactly those. +func (w *Wirer) PauseOnDrive(d Drive) ([]string, error) { + w.mu.Lock() + defer w.mu.Unlock() + prefix := d.Media() + "/" + var out []string + if w.Box.Running("qbittorrent") { + pw, _ := w.Box.Env("qbittorrent", "QBT_ADMIN_PASSWORD") + base, H, err := w.qbLogin(pw) + if err != nil { + return out, err + } + var ts []struct { + Hash string `json:"hash"` + SavePath string `json:"save_path"` + State string `json:"state"` + } + if _, err := w.jsonCall("GET", base+"/api/v2/torrents/info", H, nil, &ts); err != nil { + return out, err + } + var hs []string + for _, t := range ts { + if strings.HasPrefix(strings.TrimRight(t.SavePath, "/")+"/", prefix) && !strings.HasPrefix(t.State, "stopped") && !strings.HasPrefix(t.State, "paused") { + hs = append(hs, t.Hash) + out = append(out, "qbit:"+t.Hash) + } + } + if len(hs) > 0 { + f := url.Values{"hashes": {strings.Join(hs, "|")}} + if c, _, _, err := w.do("POST", base+"/api/v2/torrents/stop", H, strings.NewReader(f.Encode()), "application/x-www-form-urlencoded"); err != nil || c != 200 { + return out, fmt.Errorf("qBittorrent stop: HTTP %d %v", c, err) + } + } + } + if w.Box.Running("transmission") { + pw, _ := w.Box.Env("transmission", "PASS") + var got struct { + Arguments struct { + Torrents []struct { + HashString string `json:"hashString"` + DownloadDir string `json:"downloadDir"` + Status int `json:"status"` + } `json:"torrents"` + } `json:"arguments"` + } + if err := w.trRPC(pw, "torrent-get", map[string]interface{}{"fields": []string{"hashString", "downloadDir", "status"}}, &got); err != nil { + return out, err + } + var hs []string + for _, t := range got.Arguments.Torrents { + if strings.HasPrefix(strings.TrimRight(t.DownloadDir, "/")+"/", prefix) && t.Status != 0 { + hs = append(hs, t.HashString) + out = append(out, "trans:"+t.HashString) + } + } + if len(hs) > 0 { + if err := w.trRPC(pw, "torrent-stop", map[string]interface{}{"ids": hs}, nil); err != nil { + return out, err + } + } + } + w.Logger.Printf("[INFO] [mediawire] safe removal of %s: paused %d torrent(s) saved on it", d.Path, len(out)) + return out, nil +} + +// Resume starts again exactly the torrents PauseOnDrive returned. +func (w *Wirer) Resume(ids []string) error { + w.mu.Lock() + defer w.mu.Unlock() + var qb, tr []string + for _, id := range ids { + if h, ok := strings.CutPrefix(id, "qbit:"); ok { + qb = append(qb, h) + } else if h, ok := strings.CutPrefix(id, "trans:"); ok { + tr = append(tr, h) + } + } + if len(qb) > 0 && w.Box.Running("qbittorrent") { + pw, _ := w.Box.Env("qbittorrent", "QBT_ADMIN_PASSWORD") + base, H, err := w.qbLogin(pw) + if err != nil { + return err + } + f := url.Values{"hashes": {strings.Join(qb, "|")}} + if c, _, _, err := w.do("POST", base+"/api/v2/torrents/start", H, strings.NewReader(f.Encode()), "application/x-www-form-urlencoded"); err != nil || c != 200 { + return fmt.Errorf("qBittorrent start: HTTP %d %v", c, err) + } + } + if len(tr) > 0 && w.Box.Running("transmission") { + pw, _ := w.Box.Env("transmission", "PASS") + if err := w.trRPC(pw, "torrent-start", map[string]interface{}{"ids": tr}, nil); err != nil { + return err + } + } + w.Logger.Printf("[INFO] [mediawire] resumed %d torrent(s) paused by a safe removal", len(qb)+len(tr)) + return nil +} + +func (w *Wirer) qbLogin(pw string) (string, map[string]string, error) { + base := w.URL("qbittorrent", 8080) + form := url.Values{"username": {"admin"}, "password": {pw}} + code, _, hd, err := w.do("POST", base+"/api/v2/auth/login", map[string]string{"Referer": base}, strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") + m := regexp.MustCompile(`((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)`).FindStringSubmatch(strings.Join(hd.Values("Set-Cookie"), "; ")) + if err != nil || code != 200 || m == nil { + return "", nil, fmt.Errorf("qBittorrent login: HTTP %d %v", code, err) + } + return base, map[string]string{"Referer": base, "Cookie": m[1] + "=" + m[2]}, nil +} + +func (w *Wirer) trRPC(pw, method string, args map[string]interface{}, out interface{}) error { + u := w.URL("transmission", 9091) + "/transmission/rpc" + H := map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:"+pw)), "Host": "transmission:9091"} + body, _ := json.Marshal(map[string]interface{}{"method": method, "arguments": args}) + for i := 0; i < 2; i++ { + c, b, hd, err := w.do("POST", u, H, bytes.NewReader(body), "application/json") + if err != nil { + return err + } + if c == 409 { + H["X-Transmission-Session-Id"] = hd.Get("X-Transmission-Session-Id") + continue + } + if c != 200 { + return fmt.Errorf("transmission %s: HTTP %d", method, c) + } + if out != nil { + return json.Unmarshal(b, out) + } + return nil + } + return fmt.Errorf("transmission %s: no session id", method) +} + +// ── Streams (`15` §10: „restart now" cuts a playing stream, K5-4) ──────────────────────────────────────────────── + +// PlayingStreams counts what Plex and Jellyfin are playing now. Unreadable → 0 with the reason (never blocks). +func (w *Wirer) PlayingStreams() (int, []string) { + n := 0 + var notes []string + if w.Box.Running("plex") { + if tok := PlexToken(w.Box); tok != "" { + var st struct { + MediaContainer struct { + Size int `json:"size"` + } `json:"MediaContainer"` + } + if _, err := w.jsonCall("GET", w.URL("plex", 32400)+"/status/sessions", map[string]string{"Accept": "application/json", "X-Plex-Token": tok}, nil, &st); err == nil { + n += st.MediaContainer.Size + if st.MediaContainer.Size > 0 { + notes = append(notes, fmt.Sprintf("Plex: %d", st.MediaContainer.Size)) + } + } + } + } + if w.Box.Running("jellyfin") { + if key, err := w.Box.JellyfinKey(JellyfinKeyName); err == nil && key != "" { + var ss []struct { + NowPlayingItem interface{} `json:"NowPlayingItem"` + } + if _, err := w.jsonCall("GET", w.URL("jellyfin", 8096)+"/Sessions", map[string]string{"X-Emby-Token": key}, nil, &ss); err == nil { + k := 0 + for _, s := range ss { + if s.NowPlayingItem != nil { + k++ + } + } + n += k + if k > 0 { + notes = append(notes, fmt.Sprintf("Jellyfin: %d", k)) + } + } + } + } + return n, notes +} + +// repairOldRoot moves an old install's stored paths from /media/ to the same-path folder — ONLY when the container +// no longer has /media bound (the new template is applied, so the old root folder is missing) and the same-path media +// bind exists. Measured on the bench (K7): add the new root, editor rootFolderPath with moveFiles=false, rescan, delete +// the old root; Radarr's collections too. Same inode before and after. Idempotent: once no path is under /media/ it +// does nothing. Pinned by TestRepairOldRoot_K7. +func (w *Wirer) repairOldRoot(app, base string, H map[string]string, rootKind string, r *Result) error { + mounts := w.Box.Mounts(app) + if _, old := mounts["/media"]; old { + return nil // the old template still runs: /media/ is valid there + } + newMedia := "" + for target, src := range mounts { + if strings.HasSuffix(target, "/userdata/media") && filepath.Clean(src) == target { + if newMedia == "" || target < newMedia { + newMedia = target + } + } + } + if newMedia == "" { + return nil + } + oldRoot := "/media/" + rootKind + newRoot := filepath.Join(newMedia, rootKind) + kind, ids, editor, rescan := "series", "seriesIds", "/series/editor", "RescanSeries" + if app == "radarr" { + kind, ids, editor, rescan = "movie", "movieIds", "/movie/editor", "RescanMovie" + } + var items []struct { + ID int `json:"id"` + Path string `json:"path"` + } + if _, err := w.jsonCall("GET", base+"/"+kind, H, nil, &items); err != nil { + return err + } + var move []int + for _, it := range items { + if strings.HasPrefix(it.Path, oldRoot+"/") { + move = append(move, it.ID) + } + } + var roots []struct { + ID int `json:"id"` + Path string `json:"path"` + } + if _, err := w.jsonCall("GET", base+"/rootfolder", H, nil, &roots); err != nil { + return err + } + oldID, haveNew := 0, false + for _, x := range roots { + switch strings.TrimRight(x.Path, "/") { + case oldRoot: + oldID = x.ID + case newRoot: + haveNew = true + } + } + if len(move) == 0 && oldID == 0 { + return nil + } + if !haveNew { + if _, err := w.jsonCall("POST", base+"/rootfolder", H, map[string]string{"path": newRoot}, nil); err != nil { + return err + } + r.Wrote = append(r.Wrote, "rootfolder "+newRoot+" (old install)") + } + if len(move) > 0 { + body := map[string]interface{}{ids: move, "rootFolderPath": newRoot, "moveFiles": false} + if _, err := w.jsonCall("PUT", base+editor, H, body, nil); err != nil { + return err + } + _, _ = w.jsonCall("POST", base+"/command", H, map[string]string{"name": rescan}, nil) + r.Wrote = append(r.Wrote, fmt.Sprintf("moved %d %s from %s to %s (no file moved)", len(move), kind, oldRoot, newRoot)) + } + if app == "radarr" { + var cols []struct { + ID int `json:"id"` + RootFolderPath string `json:"rootFolderPath"` + } + if _, err := w.jsonCall("GET", base+"/collection", H, nil, &cols); err == nil { + var cids []int + for _, c := range cols { + if strings.TrimRight(c.RootFolderPath, "/") == oldRoot { + cids = append(cids, c.ID) + } + } + if len(cids) > 0 { + if _, err := w.jsonCall("PUT", base+"/collection", H, map[string]interface{}{"collectionIds": cids, "rootFolderPath": newRoot}, nil); err != nil { + w.Logger.Printf("[WARN] [mediawire] radarr: moving %d collection(s) to %s: %v", len(cids), newRoot, err) + } else { + r.Wrote = append(r.Wrote, fmt.Sprintf("moved %d collection(s) to %s", len(cids), newRoot)) + } + } + } + } + if oldID != 0 { + if _, err := w.jsonCall("DELETE", fmt.Sprintf("%s/rootfolder/%d", base, oldID), H, nil, nil); err != nil { + return err + } + r.Wrote = append(r.Wrote, "removed the old root folder "+oldRoot) + } + w.Logger.Printf("[INFO] [mediawire] %s: an install from before the same-path layout repaired — %s → %s, no file moved (R-1047)", app, oldRoot, newRoot) + return nil +} diff --git a/controller/internal/mediawire/mediawire_test.go b/controller/internal/mediawire/mediawire_test.go new file mode 100644 index 0000000..1d271b6 --- /dev/null +++ b/controller/internal/mediawire/mediawire_test.go @@ -0,0 +1,281 @@ +package mediawire + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" +) + +// The fakes answer the calls wire.py measured, with the answer SHAPES it recorded, and keep state — so the second run +// sees what the first one wrote. Every write is counted. + +const ( + qbPW = "qb-secret-pw-1" + trPW = "tr-secret-pw-2" + sonKey = "sonarr-key-3" + plexT = "plex-token-4" + jfKey = "jf-key-5" +) + +type fakeBox struct { + running map[string]bool + dirs []string +} + +func (b *fakeBox) Running(c string) bool { return b.running[c] } +func (b *fakeBox) Env(c, n string) (string, error) { + return map[string]string{"qbittorrent/QBT_ADMIN_PASSWORD": qbPW, "transmission/PASS": trPW}[c+"/"+n], nil +} +func (b *fakeBox) ReadFile(c, p string) ([]byte, error) { + if c == "plex" { + return []byte(``), nil + } + return []byte("" + sonKey + ""), nil +} +func (b *fakeBox) JellyfinKey(string) (string, error) { return jfKey, nil } +func (b *fakeBox) EnsureDir(p string) error { b.dirs = append(b.dirs, p); return nil } +func (b *fakeBox) Mounts(c string) map[string]string { + return map[string]string{"/media": "/mnt/felhom-drives/media_a/userdata/media"} +} + +type world struct { + mu sync.Mutex + writes []string + // qBittorrent + autoTMM bool + cats map[string]map[string]interface{} + // Sonarr + tags []map[string]interface{} + roots []map[string]interface{} + clients []map[string]interface{} + minFree int + // Plex + plexLocs []string + fsEvent map[string]bool + // Jellyfin + jfLocs []string +} + +func (w *world) wrote(s string) { w.mu.Lock(); w.writes = append(w.writes, s); w.mu.Unlock() } + +func newWorld() *world { + return &world{cats: map[string]map[string]interface{}{}, plexLocs: []string{"/media/tv"}, fsEvent: map[string]bool{}, + jfLocs: []string{"/media/tv"}} +} + +func (w *world) server(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + w.mu.Lock() + defer w.mu.Unlock() + j := func(v interface{}) { _ = json.NewEncoder(rw).Encode(v) } + p := r.URL.Path + switch { + // qBittorrent + case p == "/api/v2/auth/login": + _ = r.ParseForm() + if r.Form.Get("password") != qbPW { + rw.WriteHeader(403) + return + } + http.SetCookie(rw, &http.Cookie{Name: "QBT_SID_8080", Value: "sid"}) + case p == "/api/v2/app/preferences": + j(map[string]interface{}{"auto_tmm_enabled": w.autoTMM}) + case p == "/api/v2/app/setPreferences": + w.autoTMM = true + w.writes = append(w.writes, "qb setPreferences") + case p == "/api/v2/torrents/categories": + j(w.cats) + case p == "/api/v2/torrents/createCategory" || p == "/api/v2/torrents/editCategory": + _ = r.ParseForm() + w.cats[r.Form.Get("category")] = map[string]interface{}{"savePath": r.Form.Get("savePath"), "download_path": r.Form.Get("downloadPath")} + w.writes = append(w.writes, "qb "+p) + // Transmission + case p == "/transmission/rpc": + if r.Header.Get("X-Transmission-Session-Id") == "" { + rw.Header().Set("X-Transmission-Session-Id", "s1") + rw.WriteHeader(409) + return + } + b, _ := io.ReadAll(r.Body) + if strings.Contains(string(b), "session-set") { + w.writes = append(w.writes, "tr session-set") + } + j(map[string]interface{}{"arguments": map[string]interface{}{"incomplete-dir-enabled": false}}) + // Sonarr + case p == "/api/v3/tag" && r.Method == "GET": + j(w.tags) + case p == "/api/v3/tag": + var in map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&in) + in["id"] = len(w.tags) + 1 + w.tags = append(w.tags, in) + w.writes = append(w.writes, "sonarr tag") + j(in) + case p == "/api/v3/rootfolder" && r.Method == "GET": + j(w.roots) + case p == "/api/v3/rootfolder": + var in map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&in) + w.roots = append(w.roots, in) + w.writes = append(w.writes, "sonarr rootfolder") + j(in) + case p == "/api/v3/downloadclient" && r.Method == "GET": + j(w.clients) + case p == "/api/v3/downloadclient/schema": + j([]map[string]interface{}{ + {"implementation": "QBittorrent", "fields": []map[string]interface{}{{"name": "host"}, {"name": "password"}, {"name": "tvCategory", "value": "tv-sonarr"}}}, + {"implementation": "Transmission", "fields": []map[string]interface{}{{"name": "host"}, {"name": "password"}, {"name": "tvCategory", "value": "tv-sonarr"}, {"name": "tvDirectory"}}}, + }) + case p == "/api/v3/downloadclient": + var in map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&in) + for _, f := range in["fields"].([]interface{}) { + fm := f.(map[string]interface{}) + if fm["name"] == "tvCategory" && fm["value"] != "" && strings.HasPrefix(in["name"].(string), "Transmission") { + rw.WriteHeader(400) // „Cannot use Category and Directory" (measured) + return + } + } + w.clients = append(w.clients, in) + w.writes = append(w.writes, "sonarr downloadclient "+in["name"].(string)) + case p == "/api/v3/config/mediamanagement" && r.Method == "GET": + j(map[string]interface{}{"id": 1, "minimumFreeSpaceWhenImporting": w.minFree}) + case strings.HasPrefix(p, "/api/v3/config/mediamanagement/"): + var in map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&in) + w.minFree = int(in["minimumFreeSpaceWhenImporting"].(float64)) + w.writes = append(w.writes, "sonarr mediamanagement") + // Plex + case p == "/library/sections" && r.Method == "GET": + if r.Header.Get("X-Plex-Token") != plexT { + rw.WriteHeader(401) + return + } + var locs []map[string]string + for _, l := range w.plexLocs { + locs = append(locs, map[string]string{"path": l}) + } + j(map[string]interface{}{"MediaContainer": map[string]interface{}{"Directory": []map[string]interface{}{ + {"key": "1", "type": "show", "title": "TV", "agent": "a", "scanner": "s", "language": "hu", "Location": locs}}}}) + case p == "/library/sections/1" && r.Method == "PUT": + q, _ := url.ParseQuery(r.URL.RawQuery) + w.plexLocs = q["location"] + w.writes = append(w.writes, "plex section") + case p == "/library/sections/1/refresh": + case p == "/:/prefs" && r.Method == "GET": + var st []map[string]interface{} + for _, id := range []string{"FSEventLibraryUpdatesEnabled", "FSEventLibraryPartialScanEnabled"} { + st = append(st, map[string]interface{}{"id": id, "value": w.fsEvent[id]}) + } + j(map[string]interface{}{"MediaContainer": map[string]interface{}{"Setting": st}}) + case p == "/:/prefs" && r.Method == "PUT": + for k := range r.URL.Query() { + w.fsEvent[k] = true + } + w.writes = append(w.writes, "plex prefs") + // Jellyfin + case p == "/Library/VirtualFolders": + j([]map[string]interface{}{{"Name": "Sorozatok", "CollectionType": "tvshows", "Locations": w.jfLocs}}) + case p == "/Library/VirtualFolders/Paths": + var in map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&in) + w.jfLocs = append(w.jfLocs, in["PathInfo"].(map[string]interface{})["Path"].(string)) + w.writes = append(w.writes, "jellyfin path") + case p == "/Library/Refresh": + default: + t.Errorf("unexpected call %s %s", r.Method, p) + rw.WriteHeader(404) + } + })) +} + +// TestRun_SecondRunWritesNothing pins `15` §10 „added": one run makes drive B ready in every app; a second run of the +// same state writes NOTHING (K2: the measured second run re-sent equal qBittorrent edits — fixed here by reading first). +// The drive the players' template binds at /media is not added a second time under its same-path name. +// +// COMPANION RED-PROOF (2026-10-15): drop the „already equal" check in qbittorrent() → the second run writes 4 +// category edits and this test fails. Restored. +func TestRun_SecondRunWritesNothing(t *testing.T) { + wd := newWorld() + srv := wd.server(t) + defer srv.Close() + var logs strings.Builder + box := &fakeBox{running: map[string]bool{"qbittorrent": true, "transmission": true, "sonarr": true, "plex": true, "jellyfin": true}} + w := &Wirer{Box: box, Logger: log.New(&logs, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }} + drives := []Drive{{Path: "/mnt/felhom-drives/media_a"}, {Path: "/mnt/felhom-drives/media_b"}} + + first := w.Run(drives) + for _, r := range first { + if r.State == "error" || r.State == "household" { + t.Fatalf("first run: %s %s: %s", r.App, r.State, r.Detail) + } + } + n := len(wd.writes) + if n == 0 { + t.Fatal("the first run wrote nothing") + } + want := []string{ + "qb setPreferences", "sonarr tag", "sonarr rootfolder", "sonarr downloadclient qBittorrent media-b", + "sonarr downloadclient Transmission media-b", "sonarr mediamanagement", "plex section", "plex prefs", "jellyfin path", + } + all := strings.Join(wd.writes, "|") + for _, x := range want { + if !strings.Contains(all, x) { + t.Errorf("first run did not do %q (writes: %v)", x, wd.writes) + } + } + for _, l := range wd.plexLocs { + if l == "/mnt/felhom-drives/media_a/userdata/media/tv" { + t.Error("drive A is bound at /media for Plex and already in the library as /media/tv — added twice") + } + } + if !containsStr(wd.plexLocs, "/mnt/felhom-drives/media_b/userdata/media/tv") { + t.Errorf("drive B is not in the Plex library: %v", wd.plexLocs) + } + + w.Run(drives) + if extra := wd.writes[n:]; len(extra) != 0 { + t.Fatalf("the second run of the same state wrote %d time(s): %v", len(extra), extra) + } + for _, secret := range []string{qbPW, trPW, sonKey, plexT, jfKey} { + if strings.Contains(logs.String(), secret) { + t.Fatalf("a secret reached the log") + } + } +} + +// Plex not claimed → a household state, nothing written. +func TestRun_UnclaimedPlexIsAHouseholdStep(t *testing.T) { + wd := newWorld() + srv := wd.server(t) + defer srv.Close() + box := &unclaimedBox{fakeBox{running: map[string]bool{"plex": true}}} + w := &Wirer{Box: box, Logger: log.New(io.Discard, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }} + for _, r := range w.Run([]Drive{{Path: "/mnt/felhom-drives/media_b"}}) { + if r.App == "plex" && r.State != "household" { + t.Fatalf("an unclaimed Plex must be a household step, got %s", r.State) + } + } + if len(wd.writes) != 0 { + t.Fatalf("nothing may be written to an unclaimed Plex: %v", wd.writes) + } +} + +type unclaimedBox struct{ fakeBox } + +func (b *unclaimedBox) ReadFile(c, p string) ([]byte, error) { return []byte(``), nil } + +func containsStr(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} diff --git a/controller/internal/mediawire/repair_test.go b/controller/internal/mediawire/repair_test.go new file mode 100644 index 0000000..effdbce --- /dev/null +++ b/controller/internal/mediawire/repair_test.go @@ -0,0 +1,98 @@ +package mediawire + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// newLayoutBox: Sonarr runs the NEW template — no /media bind, the drive's media at the same path. +type newLayoutBox struct{ fakeBox } + +func (b *newLayoutBox) Mounts(string) map[string]string { + return map[string]string{"/mnt/felhom-drives/hdd_1/userdata/media": "/mnt/felhom-drives/hdd_1/userdata/media"} +} + +// TestRepairOldRoot_K7 pins R-1047 with the bench's measured sequence (K7): an install from before bc54c01 stored its +// library at /media/tv; after the update the repair adds the same-path root, moves the series' root with +// moveFiles=false, rescans, and deletes the old root — and a second run does nothing. +// +// COMPANION RED-PROOF (2026-10-15): send moveFiles=true → this test fails (a file move is never allowed). Restored. +func TestRepairOldRoot_K7(t *testing.T) { + var mu sync.Mutex + roots := []map[string]interface{}{{"id": 1, "path": "/media/tv"}} + series := []map[string]interface{}{{"id": 7, "path": "/media/tv/Problem Child"}} + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + j := func(v interface{}) { _ = json.NewEncoder(rw).Encode(v) } + switch p := r.URL.Path; { + case p == "/api/v3/series": + j(series) + case p == "/api/v3/rootfolder" && r.Method == "GET": + j(roots) + case p == "/api/v3/rootfolder" && r.Method == "POST": + var in map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&in) + in["id"] = 2 + roots = append(roots, in) + calls = append(calls, "add root "+in["path"].(string)) + case p == "/api/v3/series/editor": + var in map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&in) + if in["moveFiles"] != false { + t.Errorf("the repair asked the app to MOVE FILES: %v", in) + } + for _, s := range series { + s["path"] = strings.Replace(s["path"].(string), "/media/tv", in["rootFolderPath"].(string), 1) + } + calls = append(calls, "editor "+in["rootFolderPath"].(string)) + case p == "/api/v3/command": + calls = append(calls, "rescan") + case p == "/api/v3/rootfolder/1" && r.Method == "DELETE": + roots = roots[1:] + calls = append(calls, "delete old root") + default: + rw.WriteHeader(404) + } + })) + defer srv.Close() + w := &Wirer{Box: &newLayoutBox{}, Logger: log.New(io.Discard, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }} + var r Result + if err := w.repairOldRoot("sonarr", srv.URL+"/api/v3", nil, "tv", &r); err != nil { + t.Fatal(err) + } + want := "add root /mnt/felhom-drives/hdd_1/userdata/media/tv|editor /mnt/felhom-drives/hdd_1/userdata/media/tv|rescan|delete old root" + if got := strings.Join(calls, "|"); got != want { + t.Fatalf("repair calls:\n got %s\n want %s", got, want) + } + if series[0]["path"] != "/mnt/felhom-drives/hdd_1/userdata/media/tv/Problem Child" { + t.Fatalf("the series' stored path did not move: %v", series[0]["path"]) + } + n := len(calls) + if err := w.repairOldRoot("sonarr", srv.URL+"/api/v3", nil, "tv", &r); err != nil { + t.Fatal(err) + } + if len(calls) != n { + t.Fatalf("a second repair run wrote again: %v", calls[n:]) + } +} + +// The OLD template still running (/media bound): nothing is touched. +func TestRepairOldRoot_OldTemplateUntouched(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + t.Errorf("no call may reach an app still on the old template: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + w := &Wirer{Box: &fakeBox{}, Logger: log.New(io.Discard, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }} + var r Result + if err := w.repairOldRoot("sonarr", srv.URL+"/api/v3", nil, "tv", &r); err != nil { + t.Fatal(err) + } +} diff --git a/controller/internal/settings/media_events_test.go b/controller/internal/settings/media_events_test.go new file mode 100644 index 0000000..b186bb6 --- /dev/null +++ b/controller/internal/settings/media_events_test.go @@ -0,0 +1,56 @@ +package settings + +import ( + "testing" + "time" +) + +// The media drive events (v0.313.0): a drive change makes the restart pending until the apps restart; a safe removal +// keeps the drive registered and its paused torrents until the media apps restart after its return (R-1044). +func TestMediaEvents_PendingUntilRestarted(t *testing.T) { + s := newTestSettings(t, []StoragePath{{Path: "/mnt/felhom-drives/media_b", Label: "Media B"}}) + if err := s.MarkMediaChanged("/mnt/felhom-drives/media_b", "added"); err != nil { + t.Fatal(err) + } + if !s.GetStoragePaths()[0].MediaRestartPending() { + t.Fatal("a drive just added must make the media restart pending") + } + time.Sleep(2 * time.Millisecond) + if err := s.MarkMediaRestarted(); err != nil { + t.Fatal(err) + } + if s.GetStoragePaths()[0].MediaRestartPending() { + t.Fatal("after the restart nothing is pending") + } + time.Sleep(2 * time.Millisecond) + _ = s.MarkMediaChanged("/mnt/felhom-drives/media_b", "returned") + if !s.GetStoragePaths()[0].MediaRestartPending() { + t.Fatal("a later change makes it pending again — the timestamps must compare correctly as text") + } +} + +// COMPANION RED-PROOF (2026-10-15): make ClearEjected also clear EjectPaused → this test fails (nothing to resume after +// the restart: the household's torrents stay stopped). Restored. +func TestMediaEvents_SafeRemovalKeepsDriveAndTorrents(t *testing.T) { + s := newTestSettings(t, []StoragePath{{Path: "/mnt/felhom-drives/media_c", Label: "Media C"}}) + if err := s.SetEjected("/mnt/felhom-drives/media_c", "uuid:abc", []string{"qbit:h1", "trans:h2"}); err != nil { + t.Fatal(err) + } + sp := s.GetStoragePaths() + if len(sp) != 1 || !sp[0].Disconnected || sp[0].EjectedAt == "" { + t.Fatalf("a safe removal must keep the drive registered and mark it removed: %+v", sp) + } + if got := s.TakeResumable(); len(got) != 0 { + t.Fatalf("while the drive is away nothing may resume: %v", got) + } + if err := s.ClearEjected("/mnt/felhom-drives/media_c"); err != nil { + t.Fatal(err) + } + got := s.TakeResumable() + if len(got) != 2 { + t.Fatalf("after the return the two paused torrents must be handed to the restart: %v", got) + } + if again := s.TakeResumable(); len(again) != 0 { + t.Fatalf("taken once, never twice: %v", again) + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index fdce417..ca9fd5e 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -579,6 +579,20 @@ type StoragePath struct { DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp MigratedTo string `json:"migrated_to,omitempty"` // path of target drive + // The media drive events (v0.313.0, R-1023/R-1038/R-1044, `09` §3 decisions 238/239/245). MediaChangedAt: the drive + // was added, removed, or came back — the media apps must restart to see it; MediaRestartedAt: they were restarted + // after that (the drive card's „apps restarted since this drive changed"). Ejected*: a SAFE removal keeps the drive + // registered (R-1044): EjectedAt marks it, EjectDurableID finds it again, EjectSeenGone says the disk left after the + // eject (so its return is a return, not the still-mounted raw disk), EjectPaused lists the torrents the removal + // paused ("qbit:", "trans:") so its return resumes exactly those. + MediaChangedAt string `json:"media_changed_at,omitempty"` + MediaChange string `json:"media_change,omitempty"` // added | removed | returned + MediaRestartedAt string `json:"media_restarted_at,omitempty"` + EjectedAt string `json:"ejected_at,omitempty"` + EjectDurableID string `json:"eject_durable_id,omitempty"` + EjectSeenGone bool `json:"eject_seen_gone,omitempty"` + EjectPaused []string `json:"eject_paused,omitempty"` + // Kind discriminates a physical drive ("" / "drive") from a NAS network share ("network"). A network // share is bulk-media only and carries the fields below; the drive lifecycle does NOT apply to it. Kind string `json:"kind,omitempty"` @@ -2815,3 +2829,104 @@ func (s *Settings) ClearUnhealthyStopHold(stack string) (bool, error) { } return true, s.save() } + +// MarkMediaChanged records a media drive event (added | removed | returned) — the media apps must restart to see it. +func (s *Settings) MarkMediaChanged(path, change string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.StoragePaths { + if s.StoragePaths[i].Path == path { + s.StoragePaths[i].MediaChangedAt = time.Now().UTC().Format(mediaTS) + s.StoragePaths[i].MediaChange = change + if s.log != nil { + s.log.Printf("[INFO] [settings] media drive event: %s %s — the media apps must restart to see it", path, change) + } + return s.save() + } + } + return fmt.Errorf("storage path %q not found", path) +} + +// MarkMediaRestarted records that the media apps were restarted now, after every pending drive event. +func (s *Settings) MarkMediaRestarted() error { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC().Format(mediaTS) + for i := range s.StoragePaths { + if s.StoragePaths[i].MediaChangedAt != "" { + s.StoragePaths[i].MediaRestartedAt = now + } + } + return s.save() +} + +// MediaRestartPending reports whether a drive changed after the media apps last restarted. +func (sp StoragePath) MediaRestartPending() bool { + return sp.MediaChangedAt != "" && sp.MediaRestartedAt < sp.MediaChangedAt +} + +// SetEjected records a SAFE removal: the drive stays registered and disconnected, with the torrents the removal paused. +func (s *Settings) SetEjected(path, durableID string, paused []string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.StoragePaths { + if s.StoragePaths[i].Path == path { + sp := &s.StoragePaths[i] + sp.Disconnected, sp.DisconnectedAt = true, time.Now().UTC().Format(time.RFC3339) + sp.EjectedAt, sp.EjectDurableID, sp.EjectSeenGone, sp.EjectPaused = sp.DisconnectedAt, durableID, false, paused + return s.save() + } + } + return fmt.Errorf("storage path %q not found", path) +} + +// SetEjectSeenGone records that a safely removed drive's disk left the host after the eject. +func (s *Settings) SetEjectSeenGone(path string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.StoragePaths { + if s.StoragePaths[i].Path == path { + s.StoragePaths[i].EjectSeenGone = true + return s.save() + } + } + return fmt.Errorf("storage path %q not found", path) +} + +// ClearEjected ends a safe removal (the drive came back): connected, the eject fields cleared. +func (s *Settings) ClearEjected(path string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.StoragePaths { + if s.StoragePaths[i].Path == path { + sp := &s.StoragePaths[i] + sp.Disconnected, sp.DisconnectedAt, sp.StoppedStacks = false, "", nil + // EjectPaused is KEPT: the torrents resume after the media apps' restart (TakeResumable), when the download + // apps see the drive again — resumed before, they would report their files missing. + sp.EjectedAt, sp.EjectDurableID, sp.EjectSeenGone = "", "", false + return s.save() + } + } + return fmt.Errorf("storage path %q not found", path) +} + +// mediaTS is a FIXED-width timestamp, so two of them compare correctly as strings (RFC3339Nano trims trailing zeros). +const mediaTS = "2006-01-02T15:04:05.000000000Z" + +// TakeResumable returns, and clears, the torrents paused by a safe removal of drives that are back. +func (s *Settings) TakeResumable() []string { + s.mu.Lock() + defer s.mu.Unlock() + var out []string + for i := range s.StoragePaths { + sp := &s.StoragePaths[i] + if sp.EjectedAt == "" && len(sp.EjectPaused) > 0 { + out = append(out, sp.EjectPaused...) + sp.EjectPaused = nil + } + } + if len(out) > 0 { + _ = s.save() + } + return out +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index a1c265e..4e1ebd9 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -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 — diff --git a/controller/internal/stacks/media_library.go b/controller/internal/stacks/media_library.go index f526613..81e1734 100644 --- a/controller/internal/stacks/media_library.go +++ b/controller/internal/stacks/media_library.go @@ -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 +} diff --git a/controller/internal/stacks/setup_gate.go b/controller/internal/stacks/setup_gate.go index a5de975..4ec5f7b 100644 --- a/controller/internal/stacks/setup_gate.go +++ b/controller/internal/stacks/setup_gate.go @@ -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 } diff --git a/controller/internal/stacks/setup_gate_accept_test.go b/controller/internal/stacks/setup_gate_accept_test.go new file mode 100644 index 0000000..f5bf771 --- /dev/null +++ b/controller/internal/stacks/setup_gate_accept_test.go @@ -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(``)) + })) + 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) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 3678083..25a9e20 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -782,6 +782,8 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s // v0.280.0 (decision 46): the setup gate's card, while the gate stands. data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed() data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != "" + // R-1028 (v0.313.0): Plex is claimed on its running server — the household pastes the code here, after the install. + data["PlexClaim"] = data["SetupGateClosed"] == true && found.Meta.Slug == "plex" // R-741: the install hold's card, while the app is held for its first-login change. data["InstallHoldClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.InstallHold.Closed() // v0.282.0 (decision 49): "close sign-up now" on an app installed before the rule; the app's own switch. @@ -3478,6 +3480,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label) go s.SyncFileBrowserMounts() + s.mediaDriveEvent(path, "added") http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_added", path)), http.StatusFound) } @@ -3596,6 +3599,14 @@ func (s *Server) SyncFileBrowserMounts() { s.syncFileBrowserMounts(false) } +// SyncFileBrowserMountsForce syncs AND recreates the file browser even when nothing changed (R-1038: a returned drive). +func (s *Server) SyncFileBrowserMountsForce() { + s.fbForceMu.Lock() + s.fbForce = true + s.fbForceMu.Unlock() + s.syncFileBrowserMounts(false) +} + // SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser // database when sources change. Use only after restore — normal operations should use // SyncFileBrowserMounts to preserve user accounts, permissions, and share links. @@ -3673,6 +3684,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { // no mount behind it renders a broken sidebar entry). storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{ isMount: system.IsMountPoint, + isDead: system.MountIsShutdown, classify: s.classifyFSPath, ensureSkeleton: s.ensureUserdataSkeleton, logger: s.logger, @@ -3748,6 +3760,16 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { finalConfig, _ := os.ReadFile(configPath) finalCompose, _ := os.ReadFile(composePath) changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose) + // R-1038 (v0.313.0): a drive that CAME BACK at the same path changes neither file, yet the running file browser + // still holds the dead mount (I/O error 4 minutes after the return, measured). A forced sync recreates it. + s.fbForceMu.Lock() + if s.fbForce { + if !changed { + s.logger.Printf("[INFO] [web] FileBrowser: recreating although nothing changed — a drive came back (R-1038)") + } + changed, s.fbForce = true, false + } + s.fbForceMu.Unlock() // If sources changed and caller requested a DB reset (restore flow), // nuke the data volume so FileBrowser re-reads config.yaml from scratch. @@ -3791,6 +3813,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { // without a drive, a NAS or docker. type fbPathDeps struct { isMount func(string) bool // drive-absent gate probe (production: system.IsMountPoint) + isDead func(string) bool // a dead filesystem at the path (production: system.MountIsShutdown); nil = never classify func(string) string // network stub gate (production: Server.classifyFSPath; nil → include, fail open) ensureSkeleton func(string) error // userdata skeleton (production: appbackup.EnsureUserdataSkeleton) — DRIVES ONLY // nsRootFor resolves a bare DRIVE path to its felhom-data namespace root (R-203). Injected rather @@ -3846,6 +3869,14 @@ func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageM } continue } + // R-1038/R-1044 (v0.313.0): a DEAD drive (pulled: `shutdown` in mountinfo — no disk I/O) and a SAFELY REMOVED + // one are not bound either: the file browser lets go of them, and binds the drive again when it is back. + if sp.EjectedAt != "" || (d.isDead != nil && d.isDead(sp.Path)) { + if d.logger != nil { + d.logger.Printf("[INFO] [web] FileBrowser: drive %s is removed or dead — not bound this pass", sp.Path) + } + continue + } // SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer browses ONLY userdata — // app internals (appdata/) and the recovery units + Tier 2 copies (backups/) are NOT mounted // into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), and FileBrowser runs as diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index a8b1cee..f223cb4 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -371,6 +371,19 @@ func i18nCases() []i18nCase { }} } base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true)) + // v0.313.0 (R-1028): the Plex claim box inside a closed Plex gate — captured when born. + base = append(base, i18nCase{"app_info_plex_claim", "app_info", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Plex") + st := stacks.Stack{Name: "plex", Deployed: true, State: stacks.StateRunning} + st.Meta = stacks.Metadata{DisplayName: "Plex", Slug: "plex"} + d["Stack"] = st + d["Meta"] = st.Meta + d["AppInfo"] = st.Meta.AppInfo + d["SetupGateClosed"] = true + d["SetupGateHasProbe"] = true + d["PlexClaim"] = true + return d + }}) // R-741: the install hold's card, while an after_install app waits for its first-login change. base = append(base, i18nCase{"app_info_install_hold", "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "Calibre") diff --git a/controller/internal/web/intermediary.go b/controller/internal/web/intermediary.go index 4e65d45..adde44a 100644 --- a/controller/internal/web/intermediary.go +++ b/controller/internal/web/intermediary.go @@ -308,6 +308,7 @@ func (s *Server) ReconcileDriveGates() { if err != nil { return } + s.reconcileEjectedDrives(ctx, agent, resp.Disks) for _, a := range planDriveGates(s.settings.GetStoragePaths(), resp.Disks) { switch { case a.Stop: @@ -325,6 +326,7 @@ func (s *Server) ReconcileDriveGates() { // E-2 Part 5: when the absent drive is the BACKUP TARGET, that is the more specific and more // urgent fact, so it gets its own event rather than being folded into the generic one. s.notifyDriveAbsent(a.Path, stopped, driveTargetByPath(resp.Disks)) + s.mediaDriveEvent(a.Path, "removed") go s.SyncFileBrowserMounts() case a.Return: if a.Raw != "" { @@ -346,7 +348,9 @@ func (s *Server) ReconcileDriveGates() { // E-2b: the recovery half. An operator told a drive vanished must be told it came back — // otherwise the alarm is a dead end and the next one is trusted less. s.notifyDriveReturned(a.Path, driveTargetByPath(resp.Disks)) - go s.SyncFileBrowserMounts() + s.mediaDriveEvent(a.Path, "returned") + // R-1038: same path, same files → nothing „changed", yet the file browser holds the DEAD mount. Recreate. + go s.SyncFileBrowserMountsForce() } } } diff --git a/controller/internal/web/media_events.go b/controller/internal/web/media_events.go new file mode 100644 index 0000000..80770a2 --- /dev/null +++ b/controller/internal/web/media_events.go @@ -0,0 +1,422 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" + "gitea.dooplex.hu/admin/felhom-controller/internal/mediawire" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// ── Media drive events (v0.313.0; `09` §3 decisions 238/239/245; felhom.eu `architecture/15-media-library.md` §10) ── +// +// On a drive ADDED, REMOVED or RETURNED the media apps must restart to see it (Docker resolves binds at container +// start). The box records the event on the drive (settings: MediaChangedAt), and the storage page and the start page say +// which apps must restart, with „restart now" and „later" (tonight 03:30). Each drive card says whether the apps were +// restarted since the drive changed. „Restart now" first counts what Plex and Jellyfin are playing (a restart cuts a +// stream — K5-4) and asks once more when something plays. After the restart the box wires every present drive into the +// apps (mediawire, R-1024). A RETURNED drive also recreates the file browser at once (R-1038 — it holds no stream). +// +// A SAFE REMOVAL (R-1044) pauses only that drive's torrents, recreates the media apps and the file browser WITHOUT the +// drive, then unmounts — and keeps the drive registered, so its return needs no household step; the paused torrents +// resume when it is back. The dead-mount rule holds throughout: no path here ever makes an empty folder under an app. + +const mediaLaterHour, mediaLaterMinute = 3, 30 + +func (s *Server) wirer() *mediawire.Wirer { + s.mediaMu.Lock() + defer s.mediaMu.Unlock() + if s.mediaWirer == nil { + s.mediaWirer = mediawire.New(mediawire.DockerBox{}, s.logger) + } + return s.mediaWirer +} + +func (s *Server) mediaApps() []string { + if s.stackMgr == nil { + return nil + } + return s.stackMgr.MediaApps() +} + +func (s *Server) presentMediaDrives() []mediawire.Drive { + if s.stackMgr == nil { + return nil + } + var out []mediawire.Drive + for _, p := range s.stackMgr.PresentMediaDrives() { + out = append(out, mediawire.Drive{Path: p}) + } + return out +} + +// RunMediaWiring wires every present drive into the media apps and keeps the result for the page. +func (s *Server) RunMediaWiring(reason string) []mediawire.Result { + if len(s.mediaApps()) == 0 { + return nil + } + s.logger.Printf("[INFO] [media] wiring the drives into the media apps (%s)", reason) + res := s.wirer().Run(s.presentMediaDrives()) + s.mediaMu.Lock() + s.mediaWiring, s.mediaWiredAt = res, time.Now() + s.mediaMu.Unlock() + return res +} + +// RunMediaWiringSoon debounces the wiring after media apps come up: one run 60 s after the LAST call (an install and +// a restart of six apps make one run, not six), once more 2 min later if an app answered with an error. +func (s *Server) RunMediaWiringSoon(app string) { + s.mediaMu.Lock() + if s.mediaSoonTmr != nil { + s.mediaSoonTmr.Stop() + } + s.mediaSoonTmr = time.AfterFunc(60*time.Second, func() { + for _, r := range s.RunMediaWiring("a media app came up: " + app) { + if r.State == "error" { + time.Sleep(2 * time.Minute) + s.RunMediaWiring("retry after an app error") + return + } + } + }) + s.mediaMu.Unlock() +} + +// mediaDriveEvent records a drive added / removed / returned — only when a media app exists to care. +func (s *Server) mediaDriveEvent(path, change string) { + if s.settings == nil || len(s.mediaApps()) == 0 { + return + } + if err := s.settings.MarkMediaChanged(path, change); err != nil { + s.logger.Printf("[WARN] [media] recording the drive event %s %s: %v", change, path, err) + return + } + if s.stackMgr != nil { + // Write the new per-drive binds now (no restart): the page can say exactly which apps changed. + if changed := s.stackMgr.MediaAppsNeedingRestart(); len(changed) > 0 { + s.logger.Printf("[INFO] [media] drive %s %s: the binds of %v changed — they see it after a restart", path, change, changed) + } + } +} + +// staleMediaApps lists running media apps (and the file browser) whose own mount table still holds a DEAD drive +// (`shutdown` — K4: each container's mountinfo shows the old device after a return). No disk I/O. +func (s *Server) staleMediaApps() []string { + var out []string + for _, c := range append(s.mediaApps(), "filebrowser") { + b, err := dockerexec.Command("docker", "exec", c, "cat", "/proc/self/mountinfo").Output() + if err != nil { + continue + } + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) < 5 || !strings.HasPrefix(f[4], StableParentDir+"/") { + continue + } + if i := strings.Index(line, " - "); i >= 0 { + if t := strings.Fields(line[i+3:]); len(t) >= 3 && strings.Contains(","+t[2]+",", ",shutdown,") { + out = append(out, c) + break + } + } + } + } + return out +} + +// MediaDriveNotice is what the storage page and the start page show. +type MediaDriveNotice struct { + Pending bool `json:"pending"` + Drives []MediaNoticeDrive `json:"drives"` + Apps []string `json:"apps"` + Stale []string `json:"stale,omitempty"` + LaterAt string `json:"later_at,omitempty"` + Wiring []mediawire.Result `json:"wiring,omitempty"` + WiredAt string `json:"wired_at,omitempty"` +} + +// MediaNoticeDrive is one drive's line. +type MediaNoticeDrive struct { + Path string `json:"path"` + Label string `json:"label"` + Change string `json:"change"` + ChangedAt string `json:"changed_at"` + Restarted bool `json:"restarted"` // „apps restarted since this drive changed" + Removed bool `json:"removed,omitempty"` +} + +// MediaNotice computes the notice. Cheap enough for a page render: settings + one `docker exec cat` per media app. +func (s *Server) MediaNotice(checkStale bool) MediaDriveNotice { + n := MediaDriveNotice{Apps: s.mediaApps()} + if s.settings == nil || len(n.Apps) == 0 { + return n + } + for _, sp := range s.settings.GetStoragePaths() { + if sp.MediaChangedAt == "" { + continue + } + d := MediaNoticeDrive{Path: sp.Path, Label: sp.Label, Change: sp.MediaChange, ChangedAt: sp.MediaChangedAt, + Restarted: !sp.MediaRestartPending(), Removed: sp.EjectedAt != ""} + if d.Label == "" { + d.Label = filepath.Base(sp.Path) + } + n.Pending = n.Pending || !d.Restarted + n.Drives = append(n.Drives, d) + } + sort.Slice(n.Drives, func(i, j int) bool { return n.Drives[i].Path < n.Drives[j].Path }) + if checkStale { + n.Stale = s.staleMediaApps() + n.Pending = n.Pending || len(n.Stale) > 0 + } + s.mediaMu.Lock() + if !s.mediaLaterAt.IsZero() { + n.LaterAt = s.mediaLaterAt.Format("2006-01-02 15:04") + } + n.Wiring = s.mediaWiring + if !s.mediaWiredAt.IsZero() { + n.WiredAt = s.mediaWiredAt.Format(time.RFC3339) + } + s.mediaMu.Unlock() + return n +} + +// restartMediaApps restarts every media app for real (RestartStack: force-recreate with the stack's env — R-1026), +// recreates the file browser, records the restart on every drive, then wires the present drives in. +func (s *Server) restartMediaApps(reason string) []string { + var failed []string + for _, name := range s.mediaApps() { + if s.appHeld(name) { + continue + } + if err := s.stackMgr.RestartStack(name); err != nil { + s.logger.Printf("[WARN] [media] restart %s (%s): %v", name, reason, err) + failed = append(failed, name) + } + } + s.SyncFileBrowserMountsForce() + if len(failed) == 0 { + if err := s.settings.MarkMediaRestarted(); err != nil { + s.logger.Printf("[WARN] [media] recording the restart: %v", err) + } + } + // R-1044: the torrents a safe removal paused resume now that the download apps see their drive again (K3-6: no + // re-check after a restart). + if ids := s.settings.TakeResumable(); len(ids) > 0 { + go func() { + time.Sleep(30 * time.Second) + if err := s.wirer().Resume(ids); err != nil { + s.logger.Printf("[WARN] [media] resuming %d torrent(s) paused by a safe removal: %v", len(ids), err) + } + }() + } + s.logger.Printf("[INFO] [media] media apps restarted (%s); failed: %v", reason, failed) + return failed +} + +func nextMediaLater(now time.Time) time.Time { + loc, err := time.LoadLocation("Europe/Budapest") + if err != nil { + loc = time.Local + } + n := now.In(loc) + t := time.Date(n.Year(), n.Month(), n.Day(), mediaLaterHour, mediaLaterMinute, 0, 0, loc) + if !t.After(n) { + t = t.Add(24 * time.Hour) + } + return t +} + +// handleMediaRestart: POST /api/storage/media/restart {"when":"now"|"later","force":bool}. +func (s *Server) handleMediaRestart(w http.ResponseWriter, r *http.Request) { + var req struct { + When string `json:"when"` + Force bool `json:"force"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil) + return + } + if req.When == "later" { + at := nextMediaLater(time.Now()) + s.mediaMu.Lock() + if s.mediaLaterTmr != nil { + s.mediaLaterTmr.Stop() + } + s.mediaLaterAt = at + s.mediaLaterTmr = time.AfterFunc(time.Until(at), func() { + s.restartMediaApps("the household's „later\"") + s.mediaMu.Lock() + s.mediaLaterAt = time.Time{} + s.mediaMu.Unlock() + }) + s.mediaMu.Unlock() + s.logger.Printf("[INFO] [media] media restart scheduled for %s (the household's „later\"; a controller restart forgets it, the notice stays)", at.Format(time.RFC3339)) + writeDiskJSON(w, http.StatusOK, true, "", map[string]string{"later_at": at.Format("2006-01-02 15:04")}) + return + } + if !req.Force { + if n, notes := s.wirer().PlayingStreams(); n > 0 { + writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "media.streams_playing"), map[string]interface{}{"streams": n, "where": notes}) + return + } + } + failed := s.restartMediaApps("the household's „restart now\"") + writeDiskJSON(w, http.StatusOK, len(failed) == 0, "", map[string]interface{}{"failed": failed}) +} + +// ServeMediaAPI answers /api/storage/media/* (dispatched by ServeStorageAPI). +func (s *Server) ServeMediaAPI(w http.ResponseWriter, r *http.Request) bool { + switch { + case r.URL.Path == "/api/storage/media/status" && r.Method == http.MethodGet: + writeDiskJSON(w, http.StatusOK, true, "", s.MediaNotice(true)) + case r.URL.Path == "/api/storage/media/restart" && r.Method == http.MethodPost: + s.handleMediaRestart(w, r) + case r.URL.Path == "/api/storage/media/wire" && r.Method == http.MethodPost: + writeDiskJSON(w, http.StatusOK, true, "", s.RunMediaWiring("the household's „connect now\"")) + default: + return false + } + return true +} + +// ── Safe removal and return (R-1044) ───────────────────────────────────────────────────────────────────────────── + +// mediaSafeRemoval runs the media half of a safe removal BEFORE the agent unmounts: pause the drive's torrents, mark the +// drive removed (it stays registered), recreate the media apps and the file browser without it. Returns the paused +// torrents for the record. +func (s *Server) mediaSafeRemoval(path, durableID string) []string { + d := mediawire.Drive{Path: path} + paused, err := s.wirer().PauseOnDrive(d) + if err != nil { + s.logger.Printf("[WARN] [media] safe removal %s: pausing its torrents: %v (continuing — a paused torrent is a courtesy, the release below is the safety)", path, err) + } + if err := s.settings.SetEjected(path, durableID, paused); err != nil { + s.logger.Printf("[WARN] [media] safe removal %s: recording it: %v", path, err) + } + for _, name := range s.mediaApps() { + if s.appHeld(name) { + continue + } + // The override no longer lists the removed drive, so the recreate lets go of it. + if err := s.stackMgr.RestartStack(name); err != nil { + s.logger.Printf("[WARN] [media] safe removal %s: recreating %s without it: %v", path, name, err) + } + } + s.SyncFileBrowserMountsForce() + s.logger.Printf("[INFO] [media] safe removal %s: %d torrent(s) paused, the media apps and the file browser let go of it", path, len(paused)) + return paused +} + +// reconcileEjectedDrives handles a safely removed drive: once its disk has LEFT (not attached any more) and is BACK +// (attached again), re-bind it under the parent, clear the removal, resume its torrents, and raise the notice. +func (s *Server) reconcileEjectedDrives(ctx context.Context, agent *agentapi.Client, disks []agentapi.DiskInfo) { + for _, sp := range s.settings.GetStoragePaths() { + if sp.EjectedAt == "" { + continue + } + var disk *agentapi.DiskInfo + for i := range disks { + if (sp.EjectDurableID != "" && disks[i].DurableID == sp.EjectDurableID) || (sp.EjectDurableID == "" && disks[i].GuestPath == sp.Path) { + disk = &disks[i] + } + } + attached := disk != nil && disk.State == "attached" + if !sp.EjectSeenGone { + if !attached { + _ = s.settings.SetEjectSeenGone(sp.Path) + s.logger.Printf("[INFO] [media] safely removed drive %s: its disk has left the box", sp.Path) + } + continue + } + if !attached { + continue + } + if !disk.BoundUnderParent && disk.MountPath != "" { + if err := agent.GuestAttach(ctx, disk.MountPath); err != nil { + s.logger.Printf("[WARN] [media] safely removed drive %s is back: re-attach (raw %s): %v", sp.Path, disk.MountPath, err) + continue + } + } + if err := s.settings.ClearEjected(sp.Path); err != nil { + s.logger.Printf("[WARN] [media] safely removed drive %s is back: clearing the removal: %v", sp.Path, err) + continue + } + s.logger.Printf("[INFO] [media] safely removed drive %s is BACK — registered as before, no household step", sp.Path) + s.mediaDriveEvent(sp.Path, "returned") + // The file browser holds no stream: it gets the drive back at once. The torrents resume after the media apps' + // restart (restartMediaApps → TakeResumable). + go s.SyncFileBrowserMountsForce() + } +} + +// ejectDurableID finds the durable id of the disk behind a registered path (for finding it again after the return). +func ejectDurableID(disks []agentapi.DiskInfo, path string) string { + for _, d := range disks { + if d.GuestPath == path { + return d.DurableID + } + } + return "" +} + +var _ = settings.StoragePath{} + +// plexClaimCode is the shape of a plex.tv claim code (claim-<20-ish>); anything else is refused before a call. +var plexClaimCode = regexp.MustCompile(`^claim-[A-Za-z0-9_-]{8,64}$`) + +// appPlexClaimHandler (R-1028, `15` §10 K6): the household's code claims the RUNNING Plex — POST +// http://plex:32400/myplex/claim?token= (no Plex token needed: the server is unclaimed). The code is never logged +// and never stored. The setup gate opens only when Plex itself then says claimed (the probe), never on the press alone. +func (s *Server) appPlexClaimHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !plexClaimCode.MatchString(strings.TrimSpace(req.Code)) { + escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "err.plex_claim.bad_code")) + return + } + code, err := plexClaim(strings.TrimSpace(req.Code)) + s.logger.Printf("[INFO] [web] plex: the household's claim code given to the running server -> HTTP %d (err %v; the code is not logged)", code, err) + if err != nil || code != http.StatusOK { + escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.refused")) + return + } + for i := 0; i < 10; i++ { + if has, done, _, perr := s.stackMgr.SetupGateProbe("plex"); has && perr == nil && done { + if oerr := s.stackMgr.OpenSetupGate("plex", stacks.SetupGateByProbe); oerr != nil && !errors.Is(oerr, stacks.ErrSetupGateNotClosed) { + s.logger.Printf("[WARN] [web] plex: claimed, but opening the setup gate failed: %v", oerr) + } + s.logger.Printf("[INFO] [web] plex: claimed — the setup gate is open (R-1028)") + escrowJSON(w, http.StatusOK, map[string]any{"claimed": true}, "") + return + } + time.Sleep(time.Second) + } + escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.not_claimed")) +} + +// plexClaim is the call (a seam for tests). +var plexClaim = func(code string) (int, error) { + req, err := http.NewRequest(http.MethodPost, "http://plex:32400/myplex/claim?token="+url.QueryEscape(code), nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return 0, err + } + resp.Body.Close() + return resp.StatusCode, nil +} diff --git a/controller/internal/web/media_events_test.go b/controller/internal/web/media_events_test.go new file mode 100644 index 0000000..36a6532 --- /dev/null +++ b/controller/internal/web/media_events_test.go @@ -0,0 +1,20 @@ +package web + +import ( + "testing" + "time" +) + +// „Later" is tonight 03:30 Budapest time — or tomorrow's when it is already past 03:30. +func TestNextMediaLater(t *testing.T) { + loc, _ := time.LoadLocation("Europe/Budapest") + for now, want := range map[time.Time]string{ + time.Date(2026, 10, 15, 21, 0, 0, 0, loc): "2026-10-16 03:30", + time.Date(2026, 10, 15, 2, 0, 0, 0, loc): "2026-10-15 03:30", + time.Date(2026, 10, 15, 3, 30, 0, 0, loc): "2026-10-16 03:30", + } { + if got := nextMediaLater(now).Format("2006-01-02 15:04"); got != want { + t.Errorf("now %s: later = %s, want %s", now.Format(time.RFC3339), got, want) + } + } +} diff --git a/controller/internal/web/plex_claim_test.go b/controller/internal/web/plex_claim_test.go new file mode 100644 index 0000000..9270d37 --- /dev/null +++ b/controller/internal/web/plex_claim_test.go @@ -0,0 +1,39 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-1028: the Plex claim box renders only for a closed Plex gate — render test per branch (the seam-wiring rule). +// COMPANION RED-PROOF: remove the {{if .PlexClaim}} block → the first case fails. Restored. +func TestPlexClaimBox_RendersPerBranch(t *testing.T) { + st := lcStack("plex", "", true) + st.State = stacks.StateRunning + for _, c := range []struct { + name string + plexClaim bool + gateClosed bool + want bool + }{{"closed plex", true, true, true}, {"gate open", false, false, false}, {"other app closed", false, true, false}} { + d := appInfoData(st) + d["SetupGateClosed"] = c.gateClosed + d["PlexClaim"] = c.plexClaim + h := renderBackupPage(t, "app_info", d) + if got := strings.Contains(h, `data-plex-claim="true"`) && strings.Contains(h, `id="plex-claim-code"`); got != c.want { + t.Errorf("%s: claim box shown=%v, want %v", c.name, got, c.want) + } + } +} + +// The code is checked for its shape before any call: no call for something that is not a claim code. +func TestPlexClaimCodeShape(t *testing.T) { + for code, ok := range map[string]bool{"claim-AbCdEf123456789_-xyz": true, "claim-short": false, "claim-abcdefgh": true, "claim-": false, + "https://plex.tv/claim": false, "claim-abc def": false, "": false} { + if got := plexClaimCode.MatchString(code); got != ok { + t.Errorf("%q: accepted=%v, want %v", code, got, ok) + } + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 3733441..ae6730e 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -29,6 +29,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/integrations" + "gitea.dooplex.hu/admin/felhom-controller/internal/mediawire" "gitea.dooplex.hu/admin/felhom-controller/internal/notify" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate" @@ -91,9 +92,20 @@ type Server struct { fileBrowserMu sync.Mutex // fbReqMu guards the file-browser sync's single-flight counters (R-695, v0.275.0): fbReqGen counts // requests, fbDoneGen is the newest request a finished sync is known to have covered. - fbReqMu sync.Mutex - fbReqGen uint64 - fbDoneGen uint64 + fbReqMu sync.Mutex + // fbForce (R-1038): the next FileBrowser sync recreates even when nothing changed (a drive came back). + fbForceMu sync.Mutex + fbForce bool + // media drive events (v0.313.0): the wiring (R-1024) and the „restart later" timer. + mediaMu sync.Mutex + mediaWiring []mediawire.Result + mediaWiredAt time.Time + mediaLaterAt time.Time + mediaLaterTmr *time.Timer + mediaWirer *mediawire.Wirer + mediaSoonTmr *time.Timer + fbReqGen uint64 + fbDoneGen uint64 // syncFileBrowserHook replaces the sync's body in tests (the single-flight is the unit under test). syncFileBrowserHook func() // langSwitchSync replaces the language switch's file-browser sync in tests (R-691). @@ -850,6 +862,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // path wins. POST (not GET) so CsrfProtect covers it and it is not cacheable — see the handler. // v0.280.0: the household's two presses on the app page — "Done, I set it up" (decision 46) and "I changed // it" under a known default login (R-710). POST, so CsrfProtect covers them. + case path == "/apps/plex/plex-claim" && r.Method == http.MethodPost: + s.appPlexClaimHandler(w, r) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost: s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open")) // R-1007: the household dismisses the lost-files note. POST, so CsrfProtect covers it. diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 1af5064..ad698fc 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -315,6 +315,7 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error return util.MsgError("err.web.regisztracio_sikertelen", err) } go s.SyncFileBrowserMounts() + s.mediaDriveEvent(where, "added") // R-1023: the media apps see it after a restart return nil } @@ -370,6 +371,9 @@ func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request // ServeStorageAPI dispatches /api/storage/* (guided init/attach/eject orchestration). func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/storage/media/") && s.ServeMediaAPI(w, r) { + return + } switch { case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost: s.handleStorageInit(w, r) @@ -983,12 +987,35 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } + // R-1044 (v0.313.0): with media apps on the box, a safe removal FIRST lets go of the drive — pause only its torrents, + // recreate the media apps and the file browser without it — and KEEPS it registered (removed), so its return needs no + // household step. Measured 2026-10-14 (K3-3): the old eject left running apps holding the filesystem and forgot the + // drive („Meglévő meghajtó csatolása" after every return). + mediaRemoval := len(s.mediaApps()) > 0 && strings.HasPrefix(req.Where, StableParentDir+"/") + if mediaRemoval { + durable := "" + if dr, derr := agent.Disks(r.Context()); derr == nil { + durable = ejectDurableID(dr.Disks, req.Where) + } + s.mediaSafeRemoval(req.Where, durable) + s.mediaDriveEvent(req.Where, "removed") + } // The registered path is the STABLE /mnt/felhom-drives/; the agent operates on the raw mount. res, err := agent.EjectDisk(r.Context(), agentWhere(req.Where)) if err != nil { + if mediaRemoval { + // The unmount did not happen: the drive is still here — undo the removal mark so the apps get it back. + _ = s.settings.ClearEjected(req.Where) + s.mediaDriveEvent(req.Where, "returned") + } writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } + if mediaRemoval { + s.logger.Printf("[INFO] [web] eject: %s safely removed and KEPT registered (media apps on the box, R-1044)", req.Where) + writeDiskJSON(w, http.StatusOK, true, "", res) + return + } // Deregister the path (best-effort — the unmount already succeeded). if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil { s.logger.Printf("[WARN] [web] eject: unmounted %s but deregister failed: %v", req.Where, rerr) diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 811d939..fa3a286 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -112,6 +112,16 @@

{{T "app_info.setup_gate_title"}}

{{T "app_info.setup_gate_closed"}}

{{- if .SignupNative}}

{{T "app_info.restarts_once"}}

{{- end}} + {{- if .PlexClaim}} +
+

{{T "app_info.plex_claim_intro"}}

+
+ + +
+ +
+ {{- end}} {{- if .SetupGateHasProbe}}

{{T "app_info.setup_gate_probe"}}

@@ -368,6 +378,16 @@ function icCopyPw(btn) { {{- if or .SetupGateClosed .SignupClosed .CloseSignupOffered (and .Stack.Deployed .KnownLoginLine) .LostFiles}} +{{end}} diff --git a/controller/internal/web/templates/storage.html b/controller/internal/web/templates/storage.html index 25d8691..5b92954 100644 --- a/controller/internal/web/templates/storage.html +++ b/controller/internal/web/templates/storage.html @@ -4,6 +4,7 @@ +{{template "media_notice" .}}
diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index d58b7a6..ee90801 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -3756,3 +3756,8 @@ html::-webkit-scrollbar-track { background: var(--bg-0); } .kept-delete-form { display: flex; flex-direction: column; gap: .4rem; margin-top: .5rem; max-width: 32rem; } .kept-delete-text { color: var(--warn); margin: 0; } .storage-kept-link { margin-top: .75rem; } + +/* media drive notice (v0.313.0) */ +.media-notice-list { margin: .4rem 0 .6rem 1.1rem; padding: 0; } +.media-notice-list li { margin: .15rem 0; } +.media-notice-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin: .6rem 0; } diff --git a/controller/internal/web/testdata/i18n_parity/app_info_close_signup.html b/controller/internal/web/testdata/i18n_parity/app_info_close_signup.html index edeca7c..ce53dac 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_close_signup.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_close_signup.html @@ -595,6 +595,16 @@ + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

+ +
+ ~ RAM + + + Csak x86 + +
+ +
+
+ + +
+ + + +
+
+

Első beállítás

+

Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.

+
+

Nyisd meg a https://plex.tv/claim oldalt a Plex-fiókoddal, másold ki a kódot, és illeszd be ide. A kód 4 percig érvényes, de a Plex már fut, így nem kell sietned a telepítéssel.

+
+ + +
+ +
+

Ha kész, a doboz magától észreveszi, és mindenkinek megnyitja az alkalmazást.

+ + +
+ + + + + + + + + + +
+ + + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_button.html b/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_button.html index 0a64a88..bcd057c 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_button.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_button.html @@ -596,6 +596,16 @@ + + + + + + + + + + + + + + + + + + + + + +

Adattárolók

diff --git a/controller/internal/web/testdata/i18n_parity/storage_full.html b/controller/internal/web/testdata/i18n_parity/storage_full.html index 1222d4f..85d64ac 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_full.html +++ b/controller/internal/web/testdata/i18n_parity/storage_full.html @@ -176,6 +176,74 @@

Tárhely — Meghajtók

+ + + +

Adattárolók

diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 8233a8c..a33d114 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -239,7 +239,11 @@ "poweroff.refused.update": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.", "poweroff.refused.drive": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.", "poweroff.refused.other_guests": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.", - "poweroff.refused.generic": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence." + "poweroff.refused.generic": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.", + "err.plex_claim.bad_code": "BORN AS A KEY (v0.313.0, 2026-10-15, R-1028/R-1023) — the Plex claim on the running server and the media restart's stream check; no Go literal preceded it.", + "err.plex_claim.not_claimed": "BORN AS A KEY (v0.313.0, 2026-10-15, R-1028/R-1023) — the Plex claim on the running server and the media restart's stream check; no Go literal preceded it.", + "err.plex_claim.refused": "BORN AS A KEY (v0.313.0, 2026-10-15, R-1028/R-1023) — the Plex claim on the running server and the media restart's stream check; no Go literal preceded it.", + "media.streams_playing": "BORN AS A KEY (v0.313.0, 2026-10-15, R-1028/R-1023) — the Plex claim on the running server and the media restart's stream check; no Go literal preceded it." }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",