LabCodeHub öffentliche Ansicht
Anmelden
Küpper / ehc-tvwall öffentlich
Branch: main
ehc-tvwall / internal / config / config.go
Verlauf Rohdaten
R Rüdiger Küpper feat: add silent boot script to enhance boot experience
461e563c vor 11 Tagen
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
}