Files
felhom-controller/controller/internal/stacks/media_library.go
T

327 lines
11 KiB
Go

package stacks
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// ── Media apps on every drive (v0.313.0, R-1023; `15-media-library.md` §2, §3, §7–§10) ──────────────────────────
//
// A template marks a MEDIA app in `.felhom.yml`:
//
// media_library:
// folder: torrents # optional: only <drive>/userdata/media/<folder> (the D9 limit for a download client)
// read_only: true # a player (Plex, Jellyfin, Emby)
//
// The controller then adds, per PRESENT enrolled drive, the bind `<drive>/userdata/media[/<folder>]` at the SAME path
// inside the container. The binds live in `docker-compose.override.yml` in the stack dir, which `docker compose` merges
// by itself (no `-f` anywhere in this controller). So:
//
// - A template with the marker still runs on a controller that ignores the marker: its own one-drive
// `${USERDATA_PATH}/media…` line is untouched, and no override file is ever written.
// - ONLY present drives (`15` §10 guard 1): a bind to an absent path makes the app unstartable (K3-4: 500 „error
// while creating mount source path … permission denied"). A drive is present when it is a mountpoint AND its top
// mount is not a dead filesystem (`shutdown`, K4) — so a pulled drive is never bound, and never replaced by an
// empty folder (§10: an empty folder can read as „all files deleted").
// - A network share is never a media drive in this build (`Kind == "network"` is skipped).
// - The binds are literal host paths in a file no backup, export or removal reads: in no capture set, no `.fab`
// export, never deleted by a removal (`15` §7, §8; pinned by TestMediaLibrary_OverrideIsInNoCaptureSet and the
// R-1027 removal test).
//
// The file is rewritten before EVERY `compose up` (composeExecCustomEnv), so any start — deploy, start, restart, the
// drive notice's restart — binds exactly the drives present at that moment. Pinned by TestMediaLibrary_*.
// MediaLibrarySpec is `.felhom.yml`'s `media_library:`.
type MediaLibrarySpec struct {
// Folder limits the bind to `media/<Folder>` (qBittorrent: torrents, Transmission: transmission). Empty = the
// whole `media` folder (Sonarr, Radarr, the players).
Folder string `yaml:"folder,omitempty" json:"folder,omitempty"`
// ReadOnly binds `:ro` (the players).
ReadOnly bool `yaml:"read_only,omitempty" json:"read_only,omitempty"`
// Services names the compose services that get the binds. Empty = every service that binds `${USERDATA_PATH}/media…`.
Services []string `yaml:"services,omitempty" json:"services,omitempty"`
}
// MediaOverrideFile is the compose override the controller owns for a media app.
const MediaOverrideFile = "docker-compose.override.yml"
// mediaOverrideHeader marks the file as ours: a file without it is never touched.
const mediaOverrideHeader = "# felhom-controller media_library"
// MediaDrive is one drive the media binds are made for.
type MediaDrive struct {
Path string // the registered drive path (the felhom-data namespace root of that drive)
Present bool
Why string // why not present ("not mounted", "dead (shutdown)") — empty when present
}
// mediaDrives lists the enrolled, non-network, not decommissioned drives, each with whether it is present now. Sorted
// by path, so two renders of the same state are byte-identical.
func (m *Manager) mediaDrives() []MediaDrive {
if m.mediaDrivesFn != nil {
return m.mediaDrivesFn()
}
if m.settings == nil {
return nil
}
isMount := m.isMountPoint
if isMount == nil {
isMount = system.IsMountPoint
}
var out []MediaDrive
seen := map[string]bool{}
for _, sp := range m.settings.GetStoragePaths() {
p := filepath.Clean(sp.Path)
if sp.Kind == "network" || sp.Decommissioned || p == "" || seen[p] {
continue
}
seen[p] = true
d := MediaDrive{Path: p, Present: true}
switch {
case !isMount(p):
d.Present, d.Why = false, "not mounted"
case system.MountIsShutdown(p):
d.Present, d.Why = false, "dead (shutdown)"
}
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
// mediaBindSource is the per-drive bind source (and target) for a spec.
func mediaBindSource(drive string, spec *MediaLibrarySpec) string {
src := filepath.Join(appbackup.UserdataDir(drive), "media")
if f := strings.Trim(filepath.Clean("/"+spec.Folder), "/"); f != "" && f != "." {
src = filepath.Join(src, f)
}
return src
}
// composeServiceBinds reads each service's short-syntax volume entries, ${…} filled from env.
func composeServiceBinds(composePath string, env map[string]string) (map[string][]string, error) {
data, err := os.ReadFile(composePath)
if err != nil {
return nil, err
}
var doc struct {
Services map[string]struct {
Volumes []interface{} `yaml:"volumes"`
} `yaml:"services"`
}
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, err
}
out := map[string][]string{}
for svc, s := range doc.Services {
for _, v := range s.Volumes {
if str, ok := v.(string); ok {
out[svc] = append(out[svc], str)
}
}
}
_ = env
return out, nil
}
// bindTarget is the container side of a short-syntax bind ("src:target[:mode]"), ${…} expanded.
func bindTarget(raw string, env map[string]string) string {
parts := strings.SplitN(expandComposeVars(raw, env), ":", 3)
if len(parts) < 2 {
return ""
}
return filepath.Clean(parts[1])
}
// renderMediaOverride writes (or removes) the stack's media override for the drives present now. Returns the new file
// content ("" = no file) and whether it changed. A template without the marker gets no file; an existing file without
// our header is never touched.
func (m *Manager) renderMediaOverride(stackDir string, env []string) (string, bool) {
meta := LoadMetadata(stackDir)
path := filepath.Join(stackDir, MediaOverrideFile)
old, _ := os.ReadFile(path)
if len(old) > 0 && !strings.HasPrefix(string(old), mediaOverrideHeader) {
m.logger.Printf("[WARN] [stacks] media_library %s: %s exists and is not ours — left alone, no per-drive binds", filepath.Base(stackDir), MediaOverrideFile)
return string(old), false
}
content := ""
if meta.MediaLibrary != nil {
content = m.mediaOverrideContent(stackDir, env, meta.MediaLibrary)
}
if content == string(old) {
return content, false
}
if content == "" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
m.logger.Printf("[WARN] [stacks] media_library %s: removing the override: %v", filepath.Base(stackDir), err)
return string(old), false
}
m.logger.Printf("[INFO] [stacks] media_library %s: no per-drive bind needed — override removed", filepath.Base(stackDir))
return "", true
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
m.logger.Printf("[ERROR] [stacks] media_library %s: writing the override: %v", filepath.Base(stackDir), err)
return string(old), false
}
if err := os.Rename(tmp, path); err != nil {
m.logger.Printf("[ERROR] [stacks] media_library %s: writing the override: %v", filepath.Base(stackDir), err)
return string(old), false
}
return content, true
}
func (m *Manager) mediaOverrideContent(stackDir string, envList []string, spec *MediaLibrarySpec) string {
name := filepath.Base(stackDir)
env := map[string]string{}
for _, e := range envList {
if k, v, ok := strings.Cut(e, "="); ok {
env[k] = v
}
}
binds, err := composeServiceBinds(filepath.Join(stackDir, "docker-compose.yml"), env)
if err != nil {
m.logger.Printf("[WARN] [stacks] media_library %s: reading the compose: %v — no per-drive binds", name, err)
return ""
}
services := spec.Services
if len(services) == 0 {
for svc, vs := range binds {
for _, v := range vs {
if strings.HasPrefix(strings.TrimSpace(v), "${USERDATA_PATH}/media") {
services = append(services, svc)
break
}
}
}
}
sort.Strings(services)
mode := ""
if spec.ReadOnly {
mode = ":ro"
}
var b strings.Builder
var bound, skipped []string
for _, svc := range services {
have := map[string]bool{}
for _, v := range binds[svc] {
if t := bindTarget(v, env); t != "" {
have[t] = true
}
}
var lines []string
for _, d := range m.mediaDrives() {
src := mediaBindSource(d.Path, spec)
if !d.Present {
skipped = append(skipped, fmt.Sprintf("%s (%s)", d.Path, d.Why))
continue
}
if have[src] {
continue // the template's own line already binds this drive at this path
}
// Only on a PRESENT drive: the folder is made with the userdata convention, so Docker never makes it.
ensure := m.mediaEnsureDirFn
if ensure == nil {
ensure = appbackup.EnsureUserdataDir
}
if err := ensure(src); err != nil {
m.logger.Printf("[WARN] [stacks] media_library %s: cannot make %s: %v — that drive is not bound", name, src, err)
continue
}
lines = append(lines, fmt.Sprintf(" - %s:%s%s", src, src, mode))
bound = append(bound, src)
}
if len(lines) == 0 {
continue
}
fmt.Fprintf(&b, " %s:\n volumes:\n%s\n", svc, strings.Join(lines, "\n"))
}
m.logger.Printf("[INFO] [stacks] media_library %s: per-drive binds %d [%s]; drives not bound: [%s]",
name, len(bound), strings.Join(bound, ", "), strings.Join(uniq(skipped), ", "))
if b.Len() == 0 {
return ""
}
return mediaOverrideHeader + " — one bind per PRESENT drive (R-1023). Rewritten before every start; do not edit.\nservices:\n" + b.String()
}
func uniq(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
// MediaApps lists the deployed stacks whose template carries `media_library:`.
func (m *Manager) MediaApps() []string {
m.mu.RLock()
defer m.mu.RUnlock()
var out []string
for name, st := range m.stacks {
if !st.Deployed || st.ComposePath == "" {
continue
}
if meta := LoadMetadata(filepath.Dir(st.ComposePath)); meta.MediaLibrary != nil {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// MediaAppsNeedingRestart renders every media app's override for the drives present NOW and returns the apps whose
// binds changed — the apps that must restart to see a drive that was added, removed or came back (the notice of
// decision 238). It writes the files; the restart itself is the household's „restart now" (RestartStack).
func (m *Manager) MediaAppsNeedingRestart() []string {
var out []string
for _, name := range m.MediaApps() {
st, ok := m.GetStack(name)
if !ok {
continue
}
dir := filepath.Dir(st.ComposePath)
if _, changed := m.renderMediaOverride(dir, m.stackEnv(dir)); changed {
out = append(out, name)
}
}
return out
}
// defaultMediaDrive is where a media app's OWN config goes (R-1031): the household's default drive when it is present,
// else the first present drive; "" when no drive is present (the deploy then refuses on the required field).
func (m *Manager) defaultMediaDrive() string {
def := ""
if m.settings != nil {
for _, sp := range m.settings.GetStoragePaths() {
if sp.IsDefault && sp.Kind != "network" && !sp.Decommissioned {
def = filepath.Clean(sp.Path)
}
}
}
first := ""
for _, d := range m.mediaDrives() {
if !d.Present {
continue
}
if d.Path == def {
return def
}
if first == "" {
first = d.Path
}
}
return first
}