LabCodeHub öffentliche Ansicht
Anmelden
Küpper / ehc-tvwall öffentlich

erster Commit

R Rüdiger Küpper <rpr@9it.de> committete am 20.08.2026 23:02
47b60320cd6097935293c0f0627884f163a2bafa
Eltern: keine (erster Commit)
Dateien zu diesem Stand
5 geänderte Datei(en) +656 −0
Kontextzeilen: 0 1 2 3 10
hinzugefügt .gitignore
+4 −0 Datei ansehen
@@ -0,0 +1,4 @@
1 +
/build/
2 +
/dist/
3 +
*.tar.gz
4 +
.DS_Store
hinzugefügt config/tvwall.conf
+55 −0 Datei ansehen
@@ -0,0 +1,55 @@
1 +
# ============================================================
2 +
#  TV-Wall Konfiguration
3 +
#  Wird von install.sh und scripts/setup-ap.sh eingelesen.
4 +
#  Nach Aenderungen:  sudo /opt/tvwall/install.sh   (bzw. setup-ap.sh)
5 +
# ============================================================
6 +
7 +
# --- WLAN Access Point ------------------------------------------------------
8 +
# Name des WLANs, mit dem man sich verbindet. MUSS pro Pi eindeutig sein!
9 +
AP_SSID="TVWall-01"
10 +
11 +
# WPA2-Passwort (mindestens 8 Zeichen)
12 +
AP_PASSWORD="tvwall1234"
13 +
14 +
# Laendercode - wichtig, sonst bleibt das WLAN aus regulatorischen Gruenden aus
15 +
AP_COUNTRY="DE"
16 +
17 +
# IP-Adresse des Pi im eigenen WLAN. Das Webinterface laeuft auf dieser Adresse.
18 +
AP_IP="192.168.4.1"
19 +
20 +
# WLAN-Kanal (1-11 im 2.4-GHz-Band)
21 +
AP_CHANNEL="7"
22 +
23 +
# Hostname des Pi. Erreichbar dann auch als http://<HOSTNAME>.local
24 +
HOSTNAME="tvwall-01"
25 +
26 +
# --- Webinterface -----------------------------------------------------------
27 +
WEB_PORT="80"
28 +
29 +
# Optionales Passwort fuer das Webinterface. Leer = kein Login noetig.
30 +
WEB_PASSWORD=""
31 +
32 +
# Maximale Uploadgroesse in MB
33 +
MAX_UPLOAD_MB="10048"
34 +
35 +
# --- Wiedergabe -------------------------------------------------------------
36 +
# Anzeigedauer fuer Bilder in Sekunden (spaeter auch im Webinterface aenderbar)
37 +
IMAGE_DURATION="5"
38 +
39 +
# Ton der Videos:  yes = stumm,  no = mit Ton
40 +
MUTE="no"
41 +
42 +
# Bildschirmausgabe:
43 +
#   drm     = Pi startet in die Konsole (Raspberry Pi OS Lite) -> empfohlen
44 +
#   desktop = Pi startet in den Desktop (labwc/wayfire/X11)
45 +
DISPLAY_MODE="drm"
46 +
47 +
# Benutzer, unter dem der Player laeuft.
48 +
# Bei DISPLAY_MODE=desktop MUSS das der Desktop-Benutzer sein (meist "pi").
49 +
RUN_USER="tvwall"
50 +
51 +
# Zusaetzliche mpv-Optionen (leer lassen, wenn unsicher)
52 +
MPV_EXTRA_ARGS=""
53 +
54 +
# --- Pfade ------------------------------------------------------------------
55 +
STATE_DIR="/var/lib/tvwall"
hinzugefügt go.mod
+3 −0 Datei ansehen
@@ -0,0 +1,3 @@
1 +
module ehc/tvwall
2 +
3 +
go 1.21
hinzugefügt internal/config/config.go
+280 −0 Datei ansehen
@@ -0,0 +1,280 @@
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 +
33 +
	DisplayMode  string // "drm" oder "desktop"
34 +
	RunUser      string
35 +
	MpvExtraArgs string
36 +
37 +
	StateDir string
38 +
}
39 +
40 +
// Default liefert die Konfiguration mit allen Standardwerten.
41 +
func Default() *Config {
42 +
	return &Config{
43 +
		APSSID:        "TVWall-01",
44 +
		APPassword:    "tvwall1234",
45 +
		APCountry:     "DE",
46 +
		APIP:          "192.168.4.1",
47 +
		APChannel:     "7",
48 +
		Hostname:      "tvwall-01",
49 +
		WebPort:       80,
50 +
		MaxUploadMB:   2048,
51 +
		ImageDuration: 5,
52 +
		DisplayMode:   "drm",
53 +
		RunUser:       "tvwall",
54 +
		StateDir:      "/var/lib/tvwall",
55 +
	}
56 +
}
57 +
58 +
// Abgeleitete Pfade.
59 +
func (c *Config) MediaDir() string     { return filepath.Join(c.StateDir, "media") }
60 +
func (c *Config) ThumbDir() string     { return filepath.Join(c.StateDir, "thumbs") }
61 +
func (c *Config) OrderFile() string    { return filepath.Join(c.StateDir, "order.json") }
62 +
func (c *Config) PlaylistFile() string { return filepath.Join(c.StateDir, "playlist.m3u") }
63 +
func (c *Config) SettingsFile() string { return filepath.Join(c.StateDir, "settings.json") }
64 +
func (c *Config) MpvSocket() string    { return "/run/tvwall/mpv.sock" }
65 +
66 +
// Load liest die Konfigurationsdatei im Shell-Stil (KEY="value").
67 +
// Eine fehlende Datei ist kein Fehler - dann gelten die Standardwerte.
68 +
func Load(path string) (*Config, error) {
69 +
	c := Default()
70 +
	f, err := os.Open(path)
71 +
	if err != nil {
72 +
		if os.IsNotExist(err) {
73 +
			return c, nil
74 +
		}
75 +
		return nil, err
76 +
	}
77 +
	defer f.Close()
78 +
79 +
	sc := bufio.NewScanner(f)
80 +
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
81 +
	for lineNo := 1; sc.Scan(); lineNo++ {
82 +
		line := strings.TrimSpace(sc.Text())
83 +
		if line == "" || strings.HasPrefix(line, "#") {
84 +
			continue
85 +
		}
86 +
		line = strings.TrimPrefix(line, "export ")
87 +
		key, val, ok := strings.Cut(line, "=")
88 +
		if !ok {
89 +
			continue
90 +
		}
91 +
		key = strings.TrimSpace(key)
92 +
		val = unquote(strings.TrimSpace(val))
93 +
94 +
		switch key {
95 +
		case "AP_SSID":
96 +
			c.APSSID = val
97 +
		case "AP_PASSWORD":
98 +
			c.APPassword = val
99 +
		case "AP_COUNTRY":
100 +
			c.APCountry = val
101 +
		case "AP_IP":
102 +
			c.APIP = val
103 +
		case "AP_CHANNEL":
104 +
			c.APChannel = val
105 +
		case "HOSTNAME":
106 +
			c.Hostname = val
107 +
		case "WEB_PORT":
108 +
			if n, err := strconv.Atoi(val); err == nil && n > 0 && n < 65536 {
109 +
				c.WebPort = n
110 +
			}
111 +
		case "WEB_PASSWORD":
112 +
			c.WebPassword = val
113 +
		case "MAX_UPLOAD_MB":
114 +
			if n, err := strconv.ParseInt(val, 10, 64); err == nil && n > 0 {
115 +
				c.MaxUploadMB = n
116 +
			}
117 +
		case "IMAGE_DURATION":
118 +
			if f, err := strconv.ParseFloat(val, 64); err == nil && f > 0 {
119 +
				c.ImageDuration = f
120 +
			}
121 +
		case "MUTE":
122 +
			c.Mute = isTrue(val)
123 +
		case "DISPLAY_MODE":
124 +
			if val == "drm" || val == "desktop" {
125 +
				c.DisplayMode = val
126 +
			}
127 +
		case "RUN_USER":
128 +
			c.RunUser = val
129 +
		case "MPV_EXTRA_ARGS":
130 +
			c.MpvExtraArgs = val
131 +
		case "STATE_DIR":
132 +
			if val != "" {
133 +
				c.StateDir = val
134 +
			}
135 +
		}
136 +
	}
137 +
	return c, sc.Err()
138 +
}
139 +
140 +
func unquote(s string) string {
141 +
	// Kommentar am Zeilenende nur ausserhalb von Anfuehrungszeichen entfernen.
142 +
	if !strings.HasPrefix(s, "\"") && !strings.HasPrefix(s, "'") {
143 +
		if i := strings.Index(s, " #"); i >= 0 {
144 +
			s = strings.TrimSpace(s[:i])
145 +
		}
146 +
		return s
147 +
	}
148 +
	quote := s[0]
149 +
	if end := strings.IndexByte(s[1:], quote); end >= 0 {
150 +
		return s[1 : end+1]
151 +
	}
152 +
	return strings.Trim(s, "\"'")
153 +
}
154 +
155 +
func isTrue(s string) bool {
156 +
	switch strings.ToLower(strings.TrimSpace(s)) {
157 +
	case "yes", "true", "1", "on", "ja":
158 +
		return true
159 +
	}
160 +
	return false
161 +
}
162 +
163 +
// ---------------------------------------------------------------------------
164 +
// Laufzeit-Einstellungen (ueber das Webinterface aenderbar)
165 +
// ---------------------------------------------------------------------------
166 +
167 +
// Settings sind die im Webinterface aenderbaren Werte. Sie liegen als JSON im
168 +
// StateDir und ueberschreiben die entsprechenden Werte aus tvwall.conf.
169 +
type Settings struct {
170 +
	ImageDuration float64 `json:"image_duration"`
171 +
	Mute          bool    `json:"mute"`
172 +
}
173 +
174 +
// Store haelt die Settings im Speicher und schreibt sie atomar auf die Platte.
175 +
type Store struct {
176 +
	path string
177 +
	mu   sync.RWMutex
178 +
	s    Settings
179 +
	subs []chan struct{}
180 +
}
181 +
182 +
// NewStore laedt settings.json; fehlt die Datei, gelten die Werte aus cfg.
183 +
func NewStore(cfg *Config) *Store {
184 +
	st := &Store{
185 +
		path: cfg.SettingsFile(),
186 +
		s:    Settings{ImageDuration: cfg.ImageDuration, Mute: cfg.Mute},
187 +
	}
188 +
	if data, err := os.ReadFile(st.path); err == nil {
189 +
		var loaded Settings
190 +
		if json.Unmarshal(data, &loaded) == nil {
191 +
			if loaded.ImageDuration > 0 {
192 +
				st.s.ImageDuration = loaded.ImageDuration
193 +
			}
194 +
			st.s.Mute = loaded.Mute
195 +
		}
196 +
	}
197 +
	st.s = clamp(st.s)
198 +
	return st
199 +
}
200 +
201 +
func clamp(s Settings) Settings {
202 +
	if s.ImageDuration < 0.5 {
203 +
		s.ImageDuration = 0.5
204 +
	}
205 +
	if s.ImageDuration > 3600 {
206 +
		s.ImageDuration = 3600
207 +
	}
208 +
	return s
209 +
}
210 +
211 +
// Get liefert eine Kopie der aktuellen Einstellungen.
212 +
func (st *Store) Get() Settings {
213 +
	st.mu.RLock()
214 +
	defer st.mu.RUnlock()
215 +
	return st.s
216 +
}
217 +
218 +
// Set speichert neue Einstellungen und benachrichtigt alle Abonnenten.
219 +
func (st *Store) Set(s Settings) (Settings, error) {
220 +
	s = clamp(s)
221 +
	st.mu.Lock()
222 +
	st.s = s
223 +
	subs := append([]chan struct{}(nil), st.subs...)
224 +
	st.mu.Unlock()
225 +
226 +
	data, err := json.MarshalIndent(s, "", "  ")
227 +
	if err != nil {
228 +
		return s, err
229 +
	}
230 +
	if err := WriteFileAtomic(st.path, append(data, '\n'), 0o664); err != nil {
231 +
		return s, fmt.Errorf("settings speichern: %w", err)
232 +
	}
233 +
	for _, ch := range subs {
234 +
		select {
235 +
		case ch <- struct{}{}:
236 +
		default:
237 +
		}
238 +
	}
239 +
	return s, nil
240 +
}
241 +
242 +
// Subscribe liefert einen Kanal, der bei jeder Aenderung ein Signal bekommt.
243 +
func (st *Store) Subscribe() <-chan struct{} {
244 +
	ch := make(chan struct{}, 1)
245 +
	st.mu.Lock()
246 +
	st.subs = append(st.subs, ch)
247 +
	st.mu.Unlock()
248 +
	return ch
249 +
}
250 +
251 +
// WriteFileAtomic schreibt ueber eine temporaere Datei + rename, damit ein
252 +
// mitlesender Prozess niemals halbe Inhalte sieht.
253 +
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
254 +
	dir := filepath.Dir(path)
255 +
	if err := os.MkdirAll(dir, 0o775); err != nil {
256 +
		return err
257 +
	}
258 +
	tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")
259 +
	if err != nil {
260 +
		return err
261 +
	}
262 +
	tmpName := tmp.Name()
263 +
	defer os.Remove(tmpName)
264 +
265 +
	if _, err := tmp.Write(data); err != nil {
266 +
		tmp.Close()
267 +
		return err
268 +
	}
269 +
	if err := tmp.Sync(); err != nil {
270 +
		tmp.Close()
271 +
		return err
272 +
	}
273 +
	if err := tmp.Close(); err != nil {
274 +
		return err
275 +
	}
276 +
	if err := os.Chmod(tmpName, perm); err != nil {
277 +
		return err
278 +
	}
279 +
	return os.Rename(tmpName, path)
280 +
}
hinzugefügt internal/playlist/playlist.go
+314 −0 Datei ansehen
@@ -0,0 +1,314 @@
1 +
// Package playlist verwaltet den Medienordner: regelmaessiges Einlesen,
2 +
// persistente Sortierung und das Schreiben der m3u-Datei fuer mpv.
3 +
package playlist
4 +
5 +
import (
6 +
	"encoding/json"
7 +
	"fmt"
8 +
	"os"
9 +
	"path/filepath"
10 +
	"sort"
11 +
	"strings"
12 +
	"sync"
13 +
14 +
	"ehc/tvwall/internal/config"
15 +
)
16 +
17 +
// Unterstuetzte Dateiendungen.
18 +
var (
19 +
	imageExt = map[string]bool{
20 +
		".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
21 +
		".webp": true, ".tif": true, ".tiff": true,
22 +
	}
23 +
	videoExt = map[string]bool{
24 +
		".mp4": true, ".m4v": true, ".mov": true, ".mkv": true, ".avi": true,
25 +
		".webm": true, ".mpg": true, ".mpeg": true, ".wmv": true, ".flv": true,
26 +
		".ts": true, ".3gp": true, ".ogv": true,
27 +
	}
28 +
)
29 +
30 +
// Kind liefert "image", "video" oder "" fuer nicht unterstuetzte Dateien.
31 +
func Kind(name string) string {
32 +
	ext := strings.ToLower(filepath.Ext(name))
33 +
	switch {
34 +
	case imageExt[ext]:
35 +
		return "image"
36 +
	case videoExt[ext]:
37 +
		return "video"
38 +
	}
39 +
	return ""
40 +
}
41 +
42 +
// Supported meldet, ob die Datei abgespielt werden kann.
43 +
func Supported(name string) bool { return Kind(name) != "" }
44 +
45 +
// Entry ist ein Eintrag der Playliste.
46 +
type Entry struct {
47 +
	Name  string `json:"name"`
48 +
	Kind  string `json:"kind"`
49 +
	Size  int64  `json:"size"`
50 +
	MTime int64  `json:"mtime"`
51 +
}
52 +
53 +
// Manager haelt den aktuellen Zustand der Playliste.
54 +
type Manager struct {
55 +
	mediaDir     string
56 +
	orderFile    string
57 +
	playlistFile string
58 +
59 +
	mu      sync.RWMutex
60 +
	entries []Entry
61 +
	version uint64
62 +
63 +
	changed chan struct{}
64 +
}
65 +
66 +
// New erzeugt den Manager. Scan() muss danach mindestens einmal laufen.
67 +
func New(cfg *config.Config) *Manager {
68 +
	return &Manager{
69 +
		mediaDir:     cfg.MediaDir(),
70 +
		orderFile:    cfg.OrderFile(),
71 +
		playlistFile: cfg.PlaylistFile(),
72 +
		changed:      make(chan struct{}, 1),
73 +
	}
74 +
}
75 +
76 +
// MediaDir liefert den Medienordner.
77 +
func (m *Manager) MediaDir() string { return m.mediaDir }
78 +
79 +
// PlaylistFile liefert den Pfad der von mpv geladenen m3u-Datei.
80 +
func (m *Manager) PlaylistFile() string { return m.playlistFile }
81 +
82 +
// Changed liefert den Kanal, ueber den Aenderungen gemeldet werden.
83 +
func (m *Manager) Changed() <-chan struct{} { return m.changed }
84 +
85 +
// Entries liefert eine Kopie der aktuellen Playliste.
86 +
func (m *Manager) Entries() []Entry {
87 +
	m.mu.RLock()
88 +
	defer m.mu.RUnlock()
89 +
	return append([]Entry(nil), m.entries...)
90 +
}
91 +
92 +
// Version steigt bei jeder Aenderung - praktisch fuer das Polling im Webinterface.
93 +
func (m *Manager) Version() uint64 {
94 +
	m.mu.RLock()
95 +
	defer m.mu.RUnlock()
96 +
	return m.version
97 +
}
98 +
99 +
// Scan liest den Medienordner neu ein, mischt die gespeicherte Sortierung
100 +
// hinein und schreibt bei Aenderungen die m3u-Datei. Neue Dateien landen
101 +
// (nach Aenderungsdatum sortiert) am Ende der Playliste.
102 +
func (m *Manager) Scan() (bool, error) {
103 +
	if err := os.MkdirAll(m.mediaDir, 0o775); err != nil {
104 +
		return false, err
105 +
	}
106 +
	dirEntries, err := os.ReadDir(m.mediaDir)
107 +
	if err != nil {
108 +
		return false, err
109 +
	}
110 +
111 +
	found := make(map[string]Entry, len(dirEntries))
112 +
	var fresh []Entry
113 +
	for _, de := range dirEntries {
114 +
		name := de.Name()
115 +
		if de.IsDir() || strings.HasPrefix(name, ".") || !Supported(name) {
116 +
			continue
117 +
		}
118 +
		info, err := de.Info()
119 +
		if err != nil {
120 +
			continue
121 +
		}
122 +
		e := Entry{Name: name, Kind: Kind(name), Size: info.Size(), MTime: info.ModTime().Unix()}
123 +
		found[name] = e
124 +
		fresh = append(fresh, e)
125 +
	}
126 +
127 +
	// Neue Dateien: aeltestes zuerst, damit die Reihenfolge dem Upload folgt.
128 +
	sort.Slice(fresh, func(i, j int) bool {
129 +
		if fresh[i].MTime != fresh[j].MTime {
130 +
			return fresh[i].MTime < fresh[j].MTime
131 +
		}
132 +
		return fresh[i].Name < fresh[j].Name
133 +
	})
134 +
135 +
	stored := m.readOrder()
136 +
	seen := make(map[string]bool, len(found))
137 +
	ordered := make([]Entry, 0, len(found))
138 +
	for _, name := range stored {
139 +
		if e, ok := found[name]; ok && !seen[name] {
140 +
			seen[name] = true
141 +
			ordered = append(ordered, e)
142 +
		}
143 +
	}
144 +
	for _, e := range fresh {
145 +
		if !seen[e.Name] {
146 +
			seen[e.Name] = true
147 +
			ordered = append(ordered, e)
148 +
		}
149 +
	}
150 +
151 +
	return m.commit(ordered, len(ordered) != len(stored))
152 +
}
153 +
154 +
// SetOrder setzt die Reihenfolge neu. Nicht genannte (aber vorhandene) Dateien
155 +
// werden hinten angehaengt, damit nie etwas aus der Playliste verschwindet.
156 +
func (m *Manager) SetOrder(names []string) error {
157 +
	m.mu.RLock()
158 +
	current := append([]Entry(nil), m.entries...)
159 +
	m.mu.RUnlock()
160 +
161 +
	byName := make(map[string]Entry, len(current))
162 +
	for _, e := range current {
163 +
		byName[e.Name] = e
164 +
	}
165 +
166 +
	seen := make(map[string]bool, len(names))
167 +
	ordered := make([]Entry, 0, len(current))
168 +
	for _, n := range names {
169 +
		if e, ok := byName[n]; ok && !seen[n] {
170 +
			seen[n] = true
171 +
			ordered = append(ordered, e)
172 +
		}
173 +
	}
174 +
	for _, e := range current {
175 +
		if !seen[e.Name] {
176 +
			ordered = append(ordered, e)
177 +
		}
178 +
	}
179 +
180 +
	_, err := m.commit(ordered, true)
181 +
	return err
182 +
}
183 +
184 +
// Delete entfernt eine Datei aus dem Medienordner und der Playliste.
185 +
func (m *Manager) Delete(name string) error {
186 +
	safe, err := SafeName(name)
187 +
	if err != nil {
188 +
		return err
189 +
	}
190 +
	if err := os.Remove(filepath.Join(m.mediaDir, safe)); err != nil && !os.IsNotExist(err) {
191 +
		return err
192 +
	}
193 +
	_, err = m.Scan()
194 +
	return err
195 +
}
196 +
197 +
// commit uebernimmt die neue Reihenfolge, schreibt order.json + playlist.m3u
198 +
// und meldet die Aenderung. force erzwingt das Schreiben auch ohne Diff.
199 +
func (m *Manager) commit(ordered []Entry, force bool) (bool, error) {
200 +
	m.mu.Lock()
201 +
	changed := force || !sameEntries(m.entries, ordered)
202 +
	if !changed {
203 +
		m.mu.Unlock()
204 +
		return false, nil
205 +
	}
206 +
	// Auch bei force pruefen, ob sich inhaltlich wirklich etwas geaendert hat.
207 +
	realChange := !sameEntries(m.entries, ordered)
208 +
	m.entries = ordered
209 +
	if realChange {
210 +
		m.version++
211 +
	}
212 +
	m.mu.Unlock()
213 +
214 +
	if !realChange {
215 +
		return false, nil
216 +
	}
217 +
218 +
	names := make([]string, len(ordered))
219 +
	var m3u strings.Builder
220 +
	m3u.WriteString("#EXTM3U\n")
221 +
	for i, e := range ordered {
222 +
		names[i] = e.Name
223 +
		m3u.WriteString(filepath.Join(m.mediaDir, e.Name))
224 +
		m3u.WriteByte('\n')
225 +
	}
226 +
227 +
	if data, err := json.MarshalIndent(names, "", "  "); err == nil {
228 +
		if err := config.WriteFileAtomic(m.orderFile, append(data, '\n'), 0o664); err != nil {
229 +
			return true, fmt.Errorf("order.json schreiben: %w", err)
230 +
		}
231 +
	}
232 +
	if err := config.WriteFileAtomic(m.playlistFile, []byte(m3u.String()), 0o664); err != nil {
233 +
		return true, fmt.Errorf("playlist.m3u schreiben: %w", err)
234 +
	}
235 +
236 +
	select {
237 +
	case m.changed <- struct{}{}:
238 +
	default:
239 +
	}
240 +
	return true, nil
241 +
}
242 +
243 +
func (m *Manager) readOrder() []string {
244 +
	data, err := os.ReadFile(m.orderFile)
245 +
	if err != nil {
246 +
		return nil
247 +
	}
248 +
	var names []string
249 +
	if json.Unmarshal(data, &names) != nil {
250 +
		return nil
251 +
	}
252 +
	return names
253 +
}
254 +
255 +
func sameEntries(a, b []Entry) bool {
256 +
	if len(a) != len(b) {
257 +
		return false
258 +
	}
259 +
	for i := range a {
260 +
		if a[i].Name != b[i].Name || a[i].MTime != b[i].MTime || a[i].Size != b[i].Size {
261 +
			return false
262 +
		}
263 +
	}
264 +
	return true
265 +
}
266 +
267 +
// SafeName verhindert Pfad-Ausbrueche (../) und leere Namen.
268 +
func SafeName(name string) (string, error) {
269 +
	name = filepath.Base(strings.TrimSpace(name))
270 +
	if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
271 +
		return "", fmt.Errorf("ungueltiger Dateiname")
272 +
	}
273 +
	return name, nil
274 +
}
275 +
276 +
// CleanUploadName macht aus einem beliebigen Upload-Namen einen brauchbaren
277 +
// Dateinamen: ohne Pfad, ohne Steuerzeichen, nicht mit Punkt beginnend.
278 +
func CleanUploadName(name string) string {
279 +
	name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
280 +
	var b strings.Builder
281 +
	for _, r := range name {
282 +
		switch {
283 +
		case r < 0x20 || r == 0x7f:
284 +
			// Steuerzeichen verwerfen
285 +
		case strings.ContainsRune(`/\:*?"<>|`, r):
286 +
			b.WriteByte('_')
287 +
		default:
288 +
			b.WriteRune(r)
289 +
		}
290 +
	}
291 +
	out := strings.TrimSpace(b.String())
292 +
	out = strings.TrimLeft(out, ".")
293 +
	if len(out) > 180 {
294 +
		ext := filepath.Ext(out)
295 +
		out = out[:180-len(ext)] + ext
296 +
	}
297 +
	return out
298 +
}
299 +
300 +
// UniqueName haengt bei Namenskollisionen -1, -2, ... an.
301 +
func UniqueName(dir, name string) string {
302 +
	if _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) {
303 +
		return name
304 +
	}
305 +
	ext := filepath.Ext(name)
306 +
	base := strings.TrimSuffix(name, ext)
307 +
	for i := 1; i < 10000; i++ {
308 +
		cand := fmt.Sprintf("%s-%d%s", base, i, ext)
309 +
		if _, err := os.Stat(filepath.Join(dir, cand)); os.IsNotExist(err) {
310 +
			return cand
311 +
		}
312 +
	}
313 +
	return name
314 +
}