a09cab124a
gates / gates (push) Successful in 41s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
1013 lines
36 KiB
Go
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 && 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(`<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 && 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/<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
|
|
}
|