erster Commit
R
Rüdiger Küpper
<rpr@9it.de>
committete am 20.08.2026 23:02
47b60320cd6097935293c0f0627884f163a2bafa
| @@ -0,0 +1,4 @@ | ||
| 1 | +/build/ |
|
| 2 | +/dist/ |
|
| 3 | +*.tar.gz |
|
| 4 | +.DS_Store |
| @@ -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" |
| @@ -0,0 +1,3 @@ | ||
| 1 | +module ehc/tvwall |
|
| 2 | + | |
| 3 | +go 1.21 |
| @@ -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 | +} |
| @@ -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 | +} |