// 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 the plex container (by address, R-1060) // 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 — Plex by its ADDRESS (R-1060, plexaddr.go). func New(box Box, logger *log.Logger) *Wirer { return &Wirer{Box: box, Logger: logger, HTTP: &http.Client{Timeout: 60 * time.Second}, URL: productionURL(logger)} } // productionURL maps a container to its base URL: by name, except Plex by address. A Plex that cannot be resolved falls // back to the name (its calls then fail as before) with a WARN, never silently. func productionURL(logger *log.Logger) func(string, int) string { return func(c string, port int) string { if c == "plex" { u, err := PlexURL(port) if err == nil { return u } if logger != nil { logger.Printf("[WARN] [mediawire] plex: %v — calling it by name, which Plex refuses as non-local (R-1060)", err) } } 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 && code != 204) || m == nil { // qBittorrent 5 answers a good login 204 (measured on Tester 1) 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 && code != 204) || m == nil { // qBittorrent 5 answers a good login 204 (measured on Tester 1) 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 }