Files
felhom-controller/controller/internal/mediawire/mediawire.go
T

1013 lines
36 KiB
Go

// 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 = <d>/userdata/media, the SAME path in every container):
//
// folders : M/torrents/{tv,movies}, M/transmission/{tv,movies}
// qBittorrent : automatic torrent management ON; categories tv-<tag>, movies-<tag> (save path M/torrents/<kind>,
// 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 <tag>; root folder M/tv | M/movies; „qBittorrent <tag>" (category) and „Transmission <tag>"
// (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 <c> printenv <name>`
ReadFile(container, path string) ([]byte, error) // `docker exec <c> cat <path>`
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(`<ApiKey>([^<]+)</ApiKey>`)
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 <ApiKey> 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/<kind>; the new template binds
// the same files at the same path (<drive>/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/<kind> (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:<hash>" / "trans:<hash>" 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/<kind> 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/<kind> 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
}