internal/config/config.go
328 Zeilen · 8.1 KB · Go
| 1 | // Package config liest die Konfigurationsdatei (/etc/tvwall/tvwall.conf) sowie |
| 2 | // die zur Laufzeit im Webinterface aenderbaren Einstellungen (settings.json). |
| 3 | package config |
| 4 | |
| 5 | import ( |
| 6 | "bufio" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | ) |
| 15 | |
| 16 | // Config entspricht der Datei tvwall.conf. Die Werte werden beim Start gelesen |
| 17 | // und aendern sich zur Laufzeit nicht. |
| 18 | type Config struct { |
| 19 | APSSID string |
| 20 | APPassword string |
| 21 | APCountry string |
| 22 | APIP string |
| 23 | APChannel string |
| 24 | Hostname string |
| 25 | |
| 26 | WebPort int |
| 27 | WebPassword string |
| 28 | MaxUploadMB int64 |
| 29 | |
| 30 | ImageDuration float64 |
| 31 | Mute bool |
| 32 | Audio bool // false = Tonausgabe komplett abschalten |
| 33 | |
| 34 | ScanInterval int // Sekunden zwischen zwei Durchlaeufen des Medienordners |
| 35 | |
| 36 | DisplayMode string // "auto", "drm" oder "desktop" |
| 37 | RunUser string |
| 38 | MpvExtraArgs string |
| 39 | MpvHwdec string |
| 40 | MpvGpuAPI string |
| 41 | |
| 42 | BootLogo string // Bild, das beim Start zuerst gezeigt wird |
| 43 | BootLogoSeconds float64 // wie lange |
| 44 | FreshForVideo bool // mpv vor jedem Video neu starten |
| 45 | StallTimeout int // Sekunden ohne Fortschritt, bevor ein Video uebersprungen wird |
| 46 | |
| 47 | StateDir string |
| 48 | SocketPath string |
| 49 | } |
| 50 | |
| 51 | // Default liefert die Konfiguration mit allen Standardwerten. |
| 52 | func Default() *Config { |
| 53 | return &Config{ |
| 54 | APSSID: "TVWall-01", |
| 55 | APPassword: "tvwall1234", |
| 56 | APCountry: "DE", |
| 57 | APIP: "192.168.4.1", |
| 58 | APChannel: "7", |
| 59 | Hostname: "tvwall-01", |
| 60 | WebPort: 80, |
| 61 | MaxUploadMB: 2048, |
| 62 | ImageDuration: 5, |
| 63 | Audio: true, |
| 64 | ScanInterval: 10, |
| 65 | DisplayMode: "auto", |
| 66 | MpvHwdec: "no", |
| 67 | MpvGpuAPI: "opengl", |
| 68 | BootLogoSeconds: 8, |
| 69 | StallTimeout: 30, |
| 70 | RunUser: "tvwall", |
| 71 | StateDir: "/var/lib/tvwall", |
| 72 | SocketPath: "/run/tvwall/mpv.sock", |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // Abgeleitete Pfade. |
| 77 | func (c *Config) MediaDir() string { return filepath.Join(c.StateDir, "media") } |
| 78 | func (c *Config) ThumbDir() string { return filepath.Join(c.StateDir, "thumbs") } |
| 79 | func (c *Config) OrderFile() string { return filepath.Join(c.StateDir, "order.json") } |
| 80 | func (c *Config) DisabledFile() string { return filepath.Join(c.StateDir, "disabled.json") } |
| 81 | func (c *Config) PlaylistFile() string { return filepath.Join(c.StateDir, "playlist.m3u") } |
| 82 | func (c *Config) SettingsFile() string { return filepath.Join(c.StateDir, "settings.json") } |
| 83 | func (c *Config) MpvSocket() string { return c.SocketPath } |
| 84 | |
| 85 | // Load liest die Konfigurationsdatei im Shell-Stil (KEY="value"). |
| 86 | // Eine fehlende Datei ist kein Fehler - dann gelten die Standardwerte. |
| 87 | func Load(path string) (*Config, error) { |
| 88 | c := Default() |
| 89 | f, err := os.Open(path) |
| 90 | if err != nil { |
| 91 | if os.IsNotExist(err) { |
| 92 | return c, nil |
| 93 | } |
| 94 | return nil, err |
| 95 | } |
| 96 | defer f.Close() |
| 97 | |
| 98 | sc := bufio.NewScanner(f) |
| 99 | sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| 100 | for lineNo := 1; sc.Scan(); lineNo++ { |
| 101 | line := strings.TrimSpace(sc.Text()) |
| 102 | if line == "" || strings.HasPrefix(line, "#") { |
| 103 | continue |
| 104 | } |
| 105 | line = strings.TrimPrefix(line, "export ") |
| 106 | key, val, ok := strings.Cut(line, "=") |
| 107 | if !ok { |
| 108 | continue |
| 109 | } |
| 110 | key = strings.TrimSpace(key) |
| 111 | val = unquote(strings.TrimSpace(val)) |
| 112 | |
| 113 | switch key { |
| 114 | case "AP_SSID": |
| 115 | c.APSSID = val |
| 116 | case "AP_PASSWORD": |
| 117 | c.APPassword = val |
| 118 | case "AP_COUNTRY": |
| 119 | c.APCountry = val |
| 120 | case "AP_IP": |
| 121 | c.APIP = val |
| 122 | case "AP_CHANNEL": |
| 123 | c.APChannel = val |
| 124 | case "HOSTNAME": |
| 125 | c.Hostname = val |
| 126 | case "WEB_PORT": |
| 127 | if n, err := strconv.Atoi(val); err == nil && n > 0 && n < 65536 { |
| 128 | c.WebPort = n |
| 129 | } |
| 130 | case "WEB_PASSWORD": |
| 131 | c.WebPassword = val |
| 132 | case "MAX_UPLOAD_MB": |
| 133 | if n, err := strconv.ParseInt(val, 10, 64); err == nil && n > 0 { |
| 134 | c.MaxUploadMB = n |
| 135 | } |
| 136 | case "IMAGE_DURATION": |
| 137 | if f, err := strconv.ParseFloat(val, 64); err == nil && f > 0 { |
| 138 | c.ImageDuration = f |
| 139 | } |
| 140 | case "MUTE": |
| 141 | c.Mute = isTrue(val) |
| 142 | case "AUDIO": |
| 143 | c.Audio = isTrue(val) |
| 144 | case "SCAN_INTERVAL": |
| 145 | if n, err := strconv.Atoi(val); err == nil && n >= 1 { |
| 146 | c.ScanInterval = n |
| 147 | } |
| 148 | case "DISPLAY_MODE": |
| 149 | switch val { |
| 150 | case "auto", "drm", "desktop": |
| 151 | c.DisplayMode = val |
| 152 | } |
| 153 | case "RUN_USER": |
| 154 | c.RunUser = val |
| 155 | case "MPV_EXTRA_ARGS": |
| 156 | c.MpvExtraArgs = val |
| 157 | case "BOOT_LOGO": |
| 158 | c.BootLogo = val |
| 159 | case "BOOT_LOGO_SECONDS": |
| 160 | if f, err := strconv.ParseFloat(val, 64); err == nil && f >= 0 { |
| 161 | c.BootLogoSeconds = f |
| 162 | } |
| 163 | case "MPV_GPU_API": |
| 164 | if val != "" { |
| 165 | c.MpvGpuAPI = val |
| 166 | } |
| 167 | case "MPV_HWDEC": |
| 168 | if val != "" { |
| 169 | c.MpvHwdec = val |
| 170 | } |
| 171 | case "STALL_TIMEOUT": |
| 172 | if n, err := strconv.Atoi(val); err == nil && n >= 0 { |
| 173 | c.StallTimeout = n |
| 174 | } |
| 175 | case "MPV_SOCKET": |
| 176 | if val != "" { |
| 177 | c.SocketPath = val |
| 178 | } |
| 179 | case "STATE_DIR": |
| 180 | if val != "" { |
| 181 | c.StateDir = val |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | return c, sc.Err() |
| 186 | } |
| 187 | |
| 188 | func unquote(s string) string { |
| 189 | // Kommentar am Zeilenende nur ausserhalb von Anfuehrungszeichen entfernen. |
| 190 | if !strings.HasPrefix(s, "\"") && !strings.HasPrefix(s, "'") { |
| 191 | if i := strings.Index(s, " #"); i >= 0 { |
| 192 | s = strings.TrimSpace(s[:i]) |
| 193 | } |
| 194 | return s |
| 195 | } |
| 196 | quote := s[0] |
| 197 | if end := strings.IndexByte(s[1:], quote); end >= 0 { |
| 198 | return s[1 : end+1] |
| 199 | } |
| 200 | return strings.Trim(s, "\"'") |
| 201 | } |
| 202 | |
| 203 | func isTrue(s string) bool { |
| 204 | switch strings.ToLower(strings.TrimSpace(s)) { |
| 205 | case "yes", "true", "1", "on", "ja": |
| 206 | return true |
| 207 | } |
| 208 | return false |
| 209 | } |
| 210 | |
| 211 | // --------------------------------------------------------------------------- |
| 212 | // Laufzeit-Einstellungen (ueber das Webinterface aenderbar) |
| 213 | // --------------------------------------------------------------------------- |
| 214 | |
| 215 | // Settings sind die im Webinterface aenderbaren Werte. Sie liegen als JSON im |
| 216 | // StateDir und ueberschreiben die entsprechenden Werte aus tvwall.conf. |
| 217 | type Settings struct { |
| 218 | ImageDuration float64 `json:"image_duration"` |
| 219 | Mute bool `json:"mute"` |
| 220 | } |
| 221 | |
| 222 | // Store haelt die Settings im Speicher und schreibt sie atomar auf die Platte. |
| 223 | type Store struct { |
| 224 | path string |
| 225 | mu sync.RWMutex |
| 226 | s Settings |
| 227 | subs []chan struct{} |
| 228 | } |
| 229 | |
| 230 | // NewStore laedt settings.json; fehlt die Datei, gelten die Werte aus cfg. |
| 231 | func NewStore(cfg *Config) *Store { |
| 232 | st := &Store{ |
| 233 | path: cfg.SettingsFile(), |
| 234 | s: Settings{ImageDuration: cfg.ImageDuration, Mute: cfg.Mute}, |
| 235 | } |
| 236 | if data, err := os.ReadFile(st.path); err == nil { |
| 237 | var loaded Settings |
| 238 | if json.Unmarshal(data, &loaded) == nil { |
| 239 | if loaded.ImageDuration > 0 { |
| 240 | st.s.ImageDuration = loaded.ImageDuration |
| 241 | } |
| 242 | st.s.Mute = loaded.Mute |
| 243 | } |
| 244 | } |
| 245 | st.s = clamp(st.s) |
| 246 | return st |
| 247 | } |
| 248 | |
| 249 | func clamp(s Settings) Settings { |
| 250 | if s.ImageDuration < 0.5 { |
| 251 | s.ImageDuration = 0.5 |
| 252 | } |
| 253 | if s.ImageDuration > 3600 { |
| 254 | s.ImageDuration = 3600 |
| 255 | } |
| 256 | return s |
| 257 | } |
| 258 | |
| 259 | // Get liefert eine Kopie der aktuellen Einstellungen. |
| 260 | func (st *Store) Get() Settings { |
| 261 | st.mu.RLock() |
| 262 | defer st.mu.RUnlock() |
| 263 | return st.s |
| 264 | } |
| 265 | |
| 266 | // Set speichert neue Einstellungen und benachrichtigt alle Abonnenten. |
| 267 | func (st *Store) Set(s Settings) (Settings, error) { |
| 268 | s = clamp(s) |
| 269 | st.mu.Lock() |
| 270 | st.s = s |
| 271 | subs := append([]chan struct{}(nil), st.subs...) |
| 272 | st.mu.Unlock() |
| 273 | |
| 274 | data, err := json.MarshalIndent(s, "", " ") |
| 275 | if err != nil { |
| 276 | return s, err |
| 277 | } |
| 278 | if err := WriteFileAtomic(st.path, append(data, '\n'), 0o664); err != nil { |
| 279 | return s, fmt.Errorf("settings speichern: %w", err) |
| 280 | } |
| 281 | for _, ch := range subs { |
| 282 | select { |
| 283 | case ch <- struct{}{}: |
| 284 | default: |
| 285 | } |
| 286 | } |
| 287 | return s, nil |
| 288 | } |
| 289 | |
| 290 | // Subscribe liefert einen Kanal, der bei jeder Aenderung ein Signal bekommt. |
| 291 | func (st *Store) Subscribe() <-chan struct{} { |
| 292 | ch := make(chan struct{}, 1) |
| 293 | st.mu.Lock() |
| 294 | st.subs = append(st.subs, ch) |
| 295 | st.mu.Unlock() |
| 296 | return ch |
| 297 | } |
| 298 | |
| 299 | // WriteFileAtomic schreibt ueber eine temporaere Datei + rename, damit ein |
| 300 | // mitlesender Prozess niemals halbe Inhalte sieht. |
| 301 | func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { |
| 302 | dir := filepath.Dir(path) |
| 303 | if err := os.MkdirAll(dir, 0o775); err != nil { |
| 304 | return err |
| 305 | } |
| 306 | tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*") |
| 307 | if err != nil { |
| 308 | return err |
| 309 | } |
| 310 | tmpName := tmp.Name() |
| 311 | defer os.Remove(tmpName) |
| 312 | |
| 313 | if _, err := tmp.Write(data); err != nil { |
| 314 | tmp.Close() |
| 315 | return err |
| 316 | } |
| 317 | if err := tmp.Sync(); err != nil { |
| 318 | tmp.Close() |
| 319 | return err |
| 320 | } |
| 321 | if err := tmp.Close(); err != nil { |
| 322 | return err |
| 323 | } |
| 324 | if err := os.Chmod(tmpName, perm); err != nil { |
| 325 | return err |
| 326 | } |
| 327 | return os.Rename(tmpName, path) |
| 328 | } |