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 /userdata/media/ (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 `/userdata/media[/]` 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/` (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 sp.Disconnected || sp.EjectedAt != "": d.Present, d.Why = false, "removed" 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 } // PresentMediaDrives is the drives the media apps bind now (present, enrolled, not network, not removed). func (m *Manager) PresentMediaDrives() []string { var out []string for _, d := range m.mediaDrives() { if d.Present { out = append(out, d.Path) } } return out }