LabCodeHub öffentliche Ansicht
Anmelden
Küpper / ehc-tvwall öffentlich
Branch: main
ehc-tvwall / internal / playlist / playlist.go
Verlauf Rohdaten
R Rüdiger Küpper feat: add silent boot script to enhance boot experience
461e563c vor 11 Tagen
internal/playlist/playlist.go 400 Zeilen · 10.1 KB · Go
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
// Formate, die mpv nicht zuverlaessig anzeigt, die aber beim Hochladen
31
// angenommen und in JPEG umgewandelt werden. iPhones liefern HEIC.
32
var convertExt = map[string]bool{".heic": true, ".heif": true}
33
34
// Convertible meldet, ob die Datei beim Hochladen umgewandelt werden muss.
35
func Convertible(name string) bool {
36
	return convertExt[strings.ToLower(filepath.Ext(name))]
37
}
38
39
// UploadAllowed meldet, ob die Datei hochgeladen werden darf - entweder direkt
40
// abspielbar oder umwandelbar.
41
func UploadAllowed(name string) bool { return Supported(name) || Convertible(name) }
42
43
// Kind liefert "image", "video" oder "" fuer nicht unterstuetzte Dateien.
44
func Kind(name string) string {
45
	ext := strings.ToLower(filepath.Ext(name))
46
	switch {
47
	case imageExt[ext]:
48
		return "image"
49
	case videoExt[ext]:
50
		return "video"
51
	}
52
	return ""
53
}
54
55
// Supported meldet, ob die Datei abgespielt werden kann.
56
func Supported(name string) bool { return Kind(name) != "" }
57
58
// Entry ist ein Eintrag der Playliste.
59
type Entry struct {
60
	Name  string `json:"name"`
61
	Kind  string `json:"kind"`
62
	Size  int64  `json:"size"`
63
	MTime int64  `json:"mtime"`
64
	// Enabled=false: Die Datei bleibt liegen und steht im Webinterface, wird
65
	// auf der Wand aber nicht gezeigt.
66
	Enabled bool `json:"enabled"`
67
}
68
69
// Manager haelt den aktuellen Zustand der Playliste.
70
type Manager struct {
71
	mediaDir     string
72
	orderFile    string
73
	disabledFile string
74
	playlistFile string
75
76
	mu      sync.RWMutex
77
	entries []Entry
78
	version uint64
79
80
	changed chan struct{}
81
}
82
83
// New erzeugt den Manager. Scan() muss danach mindestens einmal laufen.
84
func New(cfg *config.Config) *Manager {
85
	return &Manager{
86
		mediaDir:     cfg.MediaDir(),
87
		orderFile:    cfg.OrderFile(),
88
		disabledFile: cfg.DisabledFile(),
89
		playlistFile: cfg.PlaylistFile(),
90
		changed:      make(chan struct{}, 1),
91
	}
92
}
93
94
// MediaDir liefert den Medienordner.
95
func (m *Manager) MediaDir() string { return m.mediaDir }
96
97
// PlaylistFile liefert den Pfad der von mpv geladenen m3u-Datei.
98
func (m *Manager) PlaylistFile() string { return m.playlistFile }
99
100
// Changed liefert den Kanal, ueber den Aenderungen gemeldet werden.
101
func (m *Manager) Changed() <-chan struct{} { return m.changed }
102
103
// Entries liefert eine Kopie der aktuellen Playliste.
104
func (m *Manager) Entries() []Entry {
105
	m.mu.RLock()
106
	defer m.mu.RUnlock()
107
	return append([]Entry(nil), m.entries...)
108
}
109
110
// EntriesWithVersion liefert Playliste und Version in einem Rutsch, damit der
111
// Player beides konsistent zueinander sieht.
112
func (m *Manager) EntriesWithVersion() ([]Entry, uint64) {
113
	m.mu.RLock()
114
	defer m.mu.RUnlock()
115
	return append([]Entry(nil), m.entries...), m.version
116
}
117
118
// Version steigt bei jeder Aenderung - praktisch fuer das Polling im Webinterface.
119
func (m *Manager) Version() uint64 {
120
	m.mu.RLock()
121
	defer m.mu.RUnlock()
122
	return m.version
123
}
124
125
// Scan liest den Medienordner neu ein, mischt die gespeicherte Sortierung
126
// hinein und schreibt bei Aenderungen die m3u-Datei. Neue Dateien landen
127
// (nach Aenderungsdatum sortiert) am Ende der Playliste.
128
func (m *Manager) Scan() (bool, error) {
129
	if err := os.MkdirAll(m.mediaDir, 0o775); err != nil {
130
		return false, err
131
	}
132
	dirEntries, err := os.ReadDir(m.mediaDir)
133
	if err != nil {
134
		return false, err
135
	}
136
137
	ausgeblendet := m.readDisabled()
138
	found := make(map[string]Entry, len(dirEntries))
139
	var fresh []Entry
140
	for _, de := range dirEntries {
141
		name := de.Name()
142
		if de.IsDir() || strings.HasPrefix(name, ".") || !Supported(name) {
143
			continue
144
		}
145
		info, err := de.Info()
146
		if err != nil {
147
			continue
148
		}
149
		e := Entry{
150
			Name: name, Kind: Kind(name),
151
			Size: info.Size(), MTime: info.ModTime().Unix(),
152
			Enabled: !ausgeblendet[name],
153
		}
154
		found[name] = e
155
		fresh = append(fresh, e)
156
	}
157
158
	// Neue Dateien: aeltestes zuerst, damit die Reihenfolge dem Upload folgt.
159
	sort.Slice(fresh, func(i, j int) bool {
160
		if fresh[i].MTime != fresh[j].MTime {
161
			return fresh[i].MTime < fresh[j].MTime
162
		}
163
		return fresh[i].Name < fresh[j].Name
164
	})
165
166
	stored := m.readOrder()
167
	seen := make(map[string]bool, len(found))
168
	ordered := make([]Entry, 0, len(found))
169
	for _, name := range stored {
170
		if e, ok := found[name]; ok && !seen[name] {
171
			seen[name] = true
172
			ordered = append(ordered, e)
173
		}
174
	}
175
	for _, e := range fresh {
176
		if !seen[e.Name] {
177
			seen[e.Name] = true
178
			ordered = append(ordered, e)
179
		}
180
	}
181
182
	return m.commit(ordered)
183
}
184
185
// SetOrder setzt die Reihenfolge neu. Nicht genannte (aber vorhandene) Dateien
186
// werden hinten angehaengt, damit nie etwas aus der Playliste verschwindet.
187
func (m *Manager) SetOrder(names []string) error {
188
	m.mu.RLock()
189
	current := append([]Entry(nil), m.entries...)
190
	m.mu.RUnlock()
191
192
	byName := make(map[string]Entry, len(current))
193
	for _, e := range current {
194
		byName[e.Name] = e
195
	}
196
197
	seen := make(map[string]bool, len(names))
198
	ordered := make([]Entry, 0, len(current))
199
	for _, n := range names {
200
		if e, ok := byName[n]; ok && !seen[n] {
201
			seen[n] = true
202
			ordered = append(ordered, e)
203
		}
204
	}
205
	for _, e := range current {
206
		if !seen[e.Name] {
207
			ordered = append(ordered, e)
208
		}
209
	}
210
211
	_, err := m.commit(ordered)
212
	return err
213
}
214
215
// SetEnabled blendet einen Eintrag auf der Wand ein oder aus. Die Datei bleibt
216
// dabei liegen und im Webinterface sichtbar.
217
func (m *Manager) SetEnabled(name string, enabled bool) error {
218
	safe, err := SafeName(name)
219
	if err != nil {
220
		return err
221
	}
222
	ausgeblendet := m.readDisabled()
223
	if enabled {
224
		delete(ausgeblendet, safe)
225
	} else {
226
		ausgeblendet[safe] = true
227
	}
228
	namen := make([]string, 0, len(ausgeblendet))
229
	for n := range ausgeblendet {
230
		namen = append(namen, n)
231
	}
232
	sort.Strings(namen)
233
	data, err := json.MarshalIndent(namen, "", "  ")
234
	if err != nil {
235
		return err
236
	}
237
	if err := config.WriteFileAtomic(m.disabledFile, append(data, '\n'), 0o664); err != nil {
238
		return fmt.Errorf("ausgeblendete eintraege speichern: %w", err)
239
	}
240
	_, err = m.Scan()
241
	return err
242
}
243
244
func (m *Manager) readDisabled() map[string]bool {
245
	out := map[string]bool{}
246
	data, err := os.ReadFile(m.disabledFile)
247
	if err != nil {
248
		return out
249
	}
250
	var namen []string
251
	if json.Unmarshal(data, &namen) != nil {
252
		return out
253
	}
254
	for _, n := range namen {
255
		out[n] = true
256
	}
257
	return out
258
}
259
260
// ActiveCount liefert die Anzahl der Eintraege, die tatsaechlich gezeigt werden.
261
func (m *Manager) ActiveCount() int {
262
	m.mu.RLock()
263
	defer m.mu.RUnlock()
264
	n := 0
265
	for _, e := range m.entries {
266
		if e.Enabled {
267
			n++
268
		}
269
	}
270
	return n
271
}
272
273
// Delete entfernt eine Datei aus dem Medienordner und der Playliste.
274
func (m *Manager) Delete(name string) error {
275
	safe, err := SafeName(name)
276
	if err != nil {
277
		return err
278
	}
279
	if err := os.Remove(filepath.Join(m.mediaDir, safe)); err != nil && !os.IsNotExist(err) {
280
		return err
281
	}
282
	_, err = m.Scan()
283
	return err
284
}
285
286
// commit uebernimmt die neue Reihenfolge, schreibt order.json + playlist.m3u
287
// und meldet die Aenderung an den Player.
288
func (m *Manager) commit(ordered []Entry) (bool, error) {
289
	m.mu.Lock()
290
	if sameEntries(m.entries, ordered) {
291
		m.mu.Unlock()
292
		return false, nil
293
	}
294
	m.entries = ordered
295
	m.version++
296
	m.mu.Unlock()
297
298
	names := make([]string, len(ordered))
299
	var m3u strings.Builder
300
	m3u.WriteString("#EXTM3U\n")
301
	for i, e := range ordered {
302
		names[i] = e.Name
303
		if !e.Enabled {
304
			continue // bleibt in der Liste, wird aber nicht abgespielt
305
		}
306
		m3u.WriteString(filepath.Join(m.mediaDir, e.Name))
307
		m3u.WriteByte('\n')
308
	}
309
310
	data, err := json.MarshalIndent(names, "", "  ")
311
	if err != nil {
312
		return true, err
313
	}
314
	if err := config.WriteFileAtomic(m.orderFile, append(data, '\n'), 0o664); err != nil {
315
		return true, fmt.Errorf("order.json schreiben: %w", err)
316
	}
317
	if err := config.WriteFileAtomic(m.playlistFile, []byte(m3u.String()), 0o664); err != nil {
318
		return true, fmt.Errorf("playlist.m3u schreiben: %w", err)
319
	}
320
321
	select {
322
	case m.changed <- struct{}{}:
323
	default:
324
	}
325
	return true, nil
326
}
327
328
func (m *Manager) readOrder() []string {
329
	data, err := os.ReadFile(m.orderFile)
330
	if err != nil {
331
		return nil
332
	}
333
	var names []string
334
	if json.Unmarshal(data, &names) != nil {
335
		return nil
336
	}
337
	return names
338
}
339
340
func sameEntries(a, b []Entry) bool {
341
	if len(a) != len(b) {
342
		return false
343
	}
344
	for i := range a {
345
		if a[i].Name != b[i].Name || a[i].MTime != b[i].MTime ||
346
			a[i].Size != b[i].Size || a[i].Enabled != b[i].Enabled {
347
			return false
348
		}
349
	}
350
	return true
351
}
352
353
// SafeName verhindert Pfad-Ausbrueche (../) und leere Namen.
354
func SafeName(name string) (string, error) {
355
	name = filepath.Base(strings.TrimSpace(name))
356
	if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
357
		return "", fmt.Errorf("ungueltiger Dateiname")
358
	}
359
	return name, nil
360
}
361
362
// CleanUploadName macht aus einem beliebigen Upload-Namen einen brauchbaren
363
// Dateinamen: ohne Pfad, ohne Steuerzeichen, nicht mit Punkt beginnend.
364
func CleanUploadName(name string) string {
365
	name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
366
	var b strings.Builder
367
	for _, r := range name {
368
		switch {
369
		case r < 0x20 || r == 0x7f:
370
			// Steuerzeichen verwerfen
371
		case strings.ContainsRune(`/\:*?"<>|`, r):
372
			b.WriteByte('_')
373
		default:
374
			b.WriteRune(r)
375
		}
376
	}
377
	out := strings.TrimSpace(b.String())
378
	out = strings.TrimLeft(out, ".")
379
	if len(out) > 180 {
380
		ext := filepath.Ext(out)
381
		out = out[:180-len(ext)] + ext
382
	}
383
	return out
384
}
385
386
// UniqueName haengt bei Namenskollisionen -1, -2, ... an.
387
func UniqueName(dir, name string) string {
388
	if _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) {
389
		return name
390
	}
391
	ext := filepath.Ext(name)
392
	base := strings.TrimSuffix(name, ext)
393
	for i := 1; i < 10000; i++ {
394
		cand := fmt.Sprintf("%s-%d%s", base, i, ext)
395
		if _, err := os.Stat(filepath.Join(dir, cand)); os.IsNotExist(err) {
396
			return cand
397
		}
398
	}
399
	return name
400
}