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

fix: v1.0.0

R Rüdiger Küpper <rpr@9it.de> committete am 21.08.2026 01:27
14518d057bcd34fd95e411d13ea882ffd2148167
29 geänderte Datei(en) +4171 −47
Kontextzeilen: 0 1 2 3 10
hinzugefügt EHC-Dienstag.jpeg
+0 −0 Datei ansehen

Binärdatei – kein Textvergleich möglich.

hinzugefügt EHC-Montag.jpeg
+0 −0 Datei ansehen

Binärdatei – kein Textvergleich möglich.

hinzugefügt EHC-Woche.jpeg
+0 −0 Datei ansehen

Binärdatei – kein Textvergleich möglich.

hinzugefügt Makefile
+56 −0 Datei ansehen
@@ -0,0 +1,56 @@
1 +
# TV-Wall - Bauen und Paketieren
2 +
#
3 +
#   make                 lokal bauen (build/tvwall)
4 +
#   make pi64            fuer Raspberry Pi OS 64 Bit (Pi 3/4/5)
5 +
#   make pi32            fuer Raspberry Pi OS 32 Bit (Pi 2/3/Zero 2)
6 +
#   make dist            fertiges Installationspaket bauen (dist/*.tar.gz)
7 +
#   make run             lokal starten (ohne mpv, Web auf :8080)
8 +
9 +
BINARY  := tvwall
10 +
VERSION ?= 1.0.0
11 +
LDFLAGS := -s -w -X main.version=$(VERSION)
12 +
PKG     := ./cmd/tvwall
13 +
14 +
.PHONY: all build pi64 pi32 dist clean run test fmt vet
15 +
16 +
all: build
17 +
18 +
build:
19 +
	@mkdir -p build
20 +
	go build -trimpath -ldflags "$(LDFLAGS)" -o build/$(BINARY) $(PKG)
21 +
22 +
pi64:
23 +
	@mkdir -p build
24 +
	GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
25 +
		go build -trimpath -ldflags "$(LDFLAGS)" -o build/$(BINARY)-linux-arm64 $(PKG)
26 +
27 +
pi32:
28 +
	@mkdir -p build
29 +
	GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 \
30 +
		go build -trimpath -ldflags "$(LDFLAGS)" -o build/$(BINARY)-linux-arm $(PKG)
31 +
32 +
# Paket zum Kopieren auf den Pi: Binaries + Installer + Konfiguration.
33 +
dist: pi64 pi32
34 +
	@rm -rf dist/$(BINARY)-$(VERSION)
35 +
	@mkdir -p dist/$(BINARY)-$(VERSION)/build
36 +
	cp build/$(BINARY)-linux-arm64 build/$(BINARY)-linux-arm dist/$(BINARY)-$(VERSION)/build/
37 +
	cp -r install.sh config systemd scripts README.md dist/$(BINARY)-$(VERSION)/
38 +
	chmod +x dist/$(BINARY)-$(VERSION)/install.sh dist/$(BINARY)-$(VERSION)/scripts/*
39 +
	cd dist && tar czf $(BINARY)-$(VERSION).tar.gz $(BINARY)-$(VERSION)
40 +
	@echo
41 +
	@echo "Fertig: dist/$(BINARY)-$(VERSION).tar.gz"
42 +
43 +
fmt:
44 +
	gofmt -w ./cmd ./internal
45 +
46 +
vet:
47 +
	go vet ./...
48 +
49 +
test:
50 +
	go test ./...
51 +
52 +
run: build
53 +
	./build/$(BINARY) -no-player -config /dev/null -state-dir ./.devstate -addr :8080
54 +
55 +
clean:
56 +
	rm -rf build dist
hinzugefügt README.md
+345 −0 Datei ansehen
@@ -0,0 +1,345 @@
1 +
# TV-Wall
2 +
3 +
Digitale Anzeigetafel für den Raspberry Pi: Bilder und Videos aus einem Ordner
4 +
laufen im Vollbild in Endlosschleife. Neue Inhalte kommen per Webinterface auf
5 +
das Gerät — der Pi spannt dafür ein eigenes WLAN auf, in das man sich mit Handy
6 +
oder Notebook einwählt.
7 +
8 +
* **Wiedergabe** — Bilder je 5 Sekunden (einstellbar), Videos komplett, danach
9 +
  von vorne. Der Ordner wird alle 10 Sekunden neu eingelesen, neue Dateien
10 +
  landen automatisch hinten in der Playliste.
11 +
* **Webinterface** — Hochladen per Fingertipp, Reihenfolge per Ziehen ändern,
12 +
  Löschen, Pause/Weiter, Anzeigedauer umstellen.
13 +
* **Access Point** — eigenes WLAN mit frei wählbarem Namen, damit sich mehrere
14 +
  Geräte („TVWall-01“, „TVWall-02“, …) sauber unterscheiden lassen.
15 +
* **Autostart** — ein systemd-Dienst, der beim Einschalten von selbst hochfährt.
16 +
17 +
Ein einziges Go-Programm ohne Fremdbibliotheken, dazu `mpv` als Abspieler.
18 +
19 +
---
20 +
21 +
## Schnellstart
22 +
23 +
### 1. Auf dem Entwicklungsrechner bauen
24 +
25 +
```bash
26 +
make dist
27 +
```
28 +
29 +
Das erzeugt `dist/tvwall-<version>.tar.gz` mit fertigen Programmdateien für
30 +
64- und 32-Bit-Raspberry-Pi-Systeme.
31 +
32 +
### 2. Auf den Pi kopieren
33 +
34 +
```bash
35 +
scp dist/tvwall-*.tar.gz pi@raspberrypi.local:~
36 +
ssh pi@raspberrypi.local
37 +
tar xzf tvwall-*.tar.gz && cd tvwall-*
38 +
```
39 +
40 +
### 3. Namen vergeben und installieren
41 +
42 +
```bash
43 +
nano config/tvwall.conf      # AP_SSID und HOSTNAME für diesen Pi setzen
44 +
sudo ./install.sh
45 +
```
46 +
47 +
Der Installer richtet alles ein: Pakete, Benutzer, Medienordner, systemd-Dienst
48 +
und den Access Point. Danach ist das Gerät sofort einsatzbereit und startet bei
49 +
jedem Einschalten von allein.
50 +
51 +
> Alternativ direkt auf dem Pi bauen: Go ≥ 1.21 installieren
52 +
> (`sudo apt install golang-go`) und `sudo ./install.sh` im Quellverzeichnis
53 +
> aufrufen — der Installer übersetzt dann selbst.
54 +
55 +
### 4. Benutzen
56 +
57 +
1. Am Handy mit dem WLAN **TVWall-01** verbinden (Passwort aus `tvwall.conf`).
58 +
2. `http://192.168.4.1` bzw. `http://tvwall` im Browser öffnen.
59 +
3. Bilder und Videos hochladen, Reihenfolge ziehen — fertig.
60 +
61 +
---
62 +
63 +
## Konfiguration
64 +
65 +
Alle Einstellungen stehen in **`/etc/tvwall/tvwall.conf`**. Nach Änderungen:
66 +
67 +
```bash
68 +
sudo systemctl restart tvwall
69 +
```
70 +
71 +
| Schlüssel | Bedeutung |
72 +
|---|---|
73 +
| `AP_SSID` | Name des WLANs — **pro Pi eindeutig vergeben** |
74 +
| `AP_PASSWORD` | WLAN-Passwort, mindestens 8 Zeichen |
75 +
| `AP_COUNTRY` | Ländercode (`DE`); ohne ihn bleibt das WLAN aus |
76 +
| `AP_IP` | Adresse des Pi im eigenen Netz (Standard `192.168.4.1`) |
77 +
| `AP_CHANNEL` | WLAN-Kanal 1–11 |
78 +
| `CAPTIVE_PORTAL` | `yes` = Handys öffnen die Seite von selbst |
79 +
| `HOSTNAME` | Gerätename, auch als `http://<name>.local` erreichbar |
80 +
| `WEB_PORT` | Port des Webinterface (Standard 80) |
81 +
| `WEB_PASSWORD` | gesetzt = Webinterface fragt nach einem Passwort |
82 +
| `MAX_UPLOAD_MB` | Obergrenze je Upload-Vorgang |
83 +
| `IMAGE_DURATION` | Anzeigedauer für Bilder in Sekunden |
84 +
| `MUTE` | `yes` = Videos ohne Ton |
85 +
| `SCAN_INTERVAL` | Takt, in dem der Ordner neu eingelesen wird |
86 +
| `DISPLAY_MODE` | `auto` (empfohlen), `drm` oder `desktop` |
87 +
| `RUN_USER` | Benutzer des Dienstes |
88 +
| `FRESH_MPV_FOR_VIDEO` | `yes` startet mpv vor jedem Video neu (umgeht den `partial file`-Abbruch) |
89 +
| `MPV_HWDEC` | `no` (Standard, Software) oder `auto-safe` (Hardware, nur Pi 4) |
90 +
| `STALL_TIMEOUT` | Sekunden ohne Fortschritt, bevor ein Video übersprungen wird |
91 +
| `MPV_EXTRA_ARGS` | zusätzliche mpv-Optionen, z. B. `--video-rotate=90` |
92 +
| `STATE_DIR` | Datenverzeichnis, Medien liegen in `<STATE_DIR>/media` |
93 +
94 +
### Mehrere Geräte
95 +
96 +
Für jeden Pi nur zwei Zeilen anpassen:
97 +
98 +
```bash
99 +
AP_SSID="TVWall-02"
100 +
HOSTNAME="tvwall-02"
101 +
```
102 +
103 +
Danach `sudo ./install.sh` — oder auf einem bereits eingerichteten Gerät nach
104 +
dem Ändern der Datei:
105 +
106 +
```bash
107 +
sudo /opt/tvwall/scripts/setup-ap.sh
108 +
```
109 +
110 +
Alle Geräte dürfen dieselbe IP `192.168.4.1` verwenden: Man ist immer nur mit
111 +
einem WLAN gleichzeitig verbunden.
112 +
113 +
### Bildausgabe: `auto`, `drm` oder `desktop`
114 +
115 +
Ein Bildschirm hat immer nur einen Herrn. Läuft ein Desktop, gehört ihm die
116 +
Grafikausgabe — mpv kommt dann nur über die Sitzung dieses Desktops daran und
117 +
**nur als der Benutzer, dem die Sitzung gehört**. Läuft kein Desktop, malt mpv
118 +
direkt über DRM/KMS. Genau darum geht es bei dieser Einstellung.
119 +
120 +
* **`auto` (empfohlen)** — der Dienst sucht beim Start nach einer erreichbaren
121 +
  Wayland- oder X11-Sitzung und nimmt sonst DRM. Der Installer erkennt zusätzlich
122 +
  am Boot-Ziel, ob der Pi in den Desktop startet, und trägt bei Bedarf den
123 +
  Desktop-Benutzer als `RUN_USER` ein.
124 +
* **`drm`** — erzwingt die direkte Ausgabe. Richtig für **Raspberry Pi OS Lite**.
125 +
  Der Installer schaltet dafür den Login-Prompt auf `tty1` ab.
126 +
* **`desktop`** — erzwingt die Ausgabe über die laufende Desktop-Sitzung. Dann
127 +
  muss `RUN_USER` der Desktop-Benutzer sein (meist `pi`) und der Pi sich
128 +
  automatisch anmelden: `sudo raspi-config` → *System Options* → *Boot / Auto
129 +
  Login* → *Desktop Autologin*.
130 +
131 +
Der Wayland-Socket heißt je nach Oberfläche `wayland-0` oder `wayland-1` — er
132 +
wird gesucht, nicht geraten. Welchen Weg der Dienst tatsächlich gewählt hat,
133 +
steht im Protokoll und unten im Webinterface.
134 +
135 +
---
136 +
137 +
## Bedienung
138 +
139 +
### Webinterface
140 +
141 +
| Bereich | Funktion |
142 +
|---|---|
143 +
| **Hochladen** | Dateien ziehen oder antippen; mehrere gleichzeitig möglich |
144 +
| **Wiedergabe** | Zurück / Pause / Weiter, „Neu laden“ springt sofort in die aktuelle Playliste |
145 +
| **Einstellungen** | Anzeigedauer für Bilder, Ton stummschalten |
146 +
| **Playliste** | Reihenfolge per Ziehen am Griff `⠿` oder mit ↑ ↓, Löschen per 🗑 |
147 +
148 +
Reihenfolge und Einstellungen bleiben über einen Neustart hinweg erhalten.
149 +
150 +
### Ohne Webinterface
151 +
152 +
Dateien lassen sich auch direkt ablegen — per USB-Stick, `scp` oder
153 +
Netzwerkfreigabe:
154 +
155 +
```bash
156 +
scp urlaub.mp4 pi@tvwall-01.local:/var/lib/tvwall/media/
157 +
```
158 +
159 +
Sie tauchen beim nächsten Durchlauf (Standard: 10 Sekunden) automatisch in der
160 +
Playliste auf.
161 +
162 +
### Unterstützte Formate
163 +
164 +
* **Bilder:** jpg, jpeg, png, gif, bmp, webp, tif, tiff
165 +
* **iPhone-Fotos:** heic und heif werden beim Hochladen automatisch in JPEG
166 +
  umgewandelt (dafür wird ffmpeg gebraucht)
167 +
* **Videos:** mp4, m4v, mov, mkv, avi, webm, mpg, mpeg, wmv, flv, ts, 3gp, ogv
168 +
169 +
Alles andere wird beim Hochladen abgelehnt und im Ordner ignoriert.
170 +
171 +
---
172 +
173 +
## Betrieb
174 +
175 +
```bash
176 +
sudo /opt/tvwall/scripts/diagnose.sh   # Selbsttest bei Problemen
177 +
systemctl status tvwall          # Zustand
178 +
journalctl -u tvwall -f          # Protokoll mitlesen
179 +
sudo systemctl restart tvwall    # neu starten
180 +
sudo systemctl disable tvwall    # Autostart abschalten
181 +
```
182 +
183 +
Aktualisieren: neues Paket auspacken und `sudo ./install.sh` erneut ausführen.
184 +
Eine vorhandene `/etc/tvwall/tvwall.conf` bleibt dabei unangetastet; die neue
185 +
Fassung landet als `tvwall.conf.neu` daneben.
186 +
187 +
Entfernen:
188 +
189 +
```bash
190 +
sudo /opt/tvwall/scripts/uninstall.sh          # Medien bleiben erhalten
191 +
sudo /opt/tvwall/scripts/uninstall.sh --purge  # alles löschen
192 +
```
193 +
194 +
---
195 +
196 +
## Wenn etwas klemmt
197 +
198 +
**Der Bildschirm bleibt schwarz.**
199 +
Zuerst den Selbsttest laufen lassen — er prüft Dienst, Benutzer, Gruppen,
200 +
Bildschirm, Medien, Webinterface und Access Point und nennt die nötigen Schritte:
201 +
202 +
```bash
203 +
sudo /opt/tvwall/scripts/diagnose.sh
204 +
```
205 +
206 +
Die beiden häufigsten Ursachen:
207 +
208 +
1. **Ein Desktop läuft, der Dienst aber unter dem falschen Benutzer.** Die
209 +
   Desktop-Sitzung von `pi` liegt in `/run/user/1000` und ist für den Benutzer
210 +
   `tvwall` schlicht nicht zugänglich. Abhilfe: in `/etc/tvwall/tvwall.conf`
211 +
   `RUN_USER="pi"` und `DISPLAY_MODE="desktop"` setzen, dann
212 +
   `sudo /opt/tvwall/install.sh --no-ap`.
213 +
2. **`DISPLAY_MODE="drm"` auf einem Desktop-System.** Der Compositor hält den
214 +
   Bildschirm bereits, mpv kommt nicht daran. Abhilfe: `DISPLAY_MODE="auto"` —
215 +
   oder in `raspi-config` auf *Console Autologin* umstellen.
216 +
217 +
Zum Prüfen, ob mpv überhaupt ein Bild bekommt (Dienst vorher mit
218 +
`sudo systemctl stop tvwall` anhalten):
219 +
220 +
```bash
221 +
# mit Desktop
222 +
sudo -u pi XDG_RUNTIME_DIR=/run/user/1000 mpv --fullscreen /var/lib/tvwall/media/<datei>
223 +
# ohne Desktop
224 +
sudo -u tvwall mpv --vo=gpu --gpu-context=drm --fullscreen /var/lib/tvwall/media/<datei>
225 +
```
226 +
227 +
**Das WLAN taucht nicht auf.**
228 +
229 +
```bash
230 +
nmcli connection show --active     # läuft tvwall-ap?
231 +
rfkill list                        # Funk gesperrt?
232 +
sudo /opt/tvwall/scripts/setup-ap.sh
233 +
```
234 +
235 +
Häufigste Ursache: Der Pi ist noch mit einem anderen WLAN verbunden. Eine
236 +
Schnittstelle kann nicht gleichzeitig Client und Access Point sein —
237 +
andere WLAN-Verbindungen mit `nmcli connection modify <name> connection.autoconnect no`
238 +
abschalten. Zweithäufigste Ursache: fehlender Ländercode (`AP_COUNTRY`).
239 +
240 +
**Webinterface nicht erreichbar.**
241 +
Zuerst `http://192.168.4.1` probieren — Namen wie `tvwall.local` funktionieren
242 +
nicht auf allen Handys. Läuft der Dienst? `systemctl status tvwall`.
243 +
244 +
**Ein Video bleibt nach ein paar Sekunden stehen.**
245 +
Seit Version 1.0.0 wird jede Datei einzeln an mpv übergeben; der früher
246 +
beobachtete Abbruch mit `partial file` beim Übergang Bild → Video ist damit
247 +
erledigt. Tritt trotzdem etwas auf, misst `scripts/bisect-mpv.sh` jede Variante
248 +
einzeln durch.
249 +
250 +
Zuerst `MPV_HWDEC="no"` prüfen (seit Version 1.0.0 die Voreinstellung). Der
251 +
Raspberry Pi 5 hat *keinen* Hardware-Dekoder für H.264 mehr; mit `auto-safe`
252 +
läuft ein Video dort ein bis zwei Sekunden und friert dann ein — genau so lange,
253 +
wie der bereits gefüllte Puffer reicht. Zum Gegenprüfen hilft der direkte
254 +
Vergleich, weil mpv von sich aus `--hwdec=no` verwendet:
255 +
256 +
```bash
257 +
sudo systemctl stop tvwall
258 +
mpv --hwdec=no        --fullscreen /var/lib/tvwall/media/<datei>   # läuft
259 +
mpv --hwdec=auto-safe --fullscreen /var/lib/tvwall/media/<datei>   # friert ein
260 +
sudo systemctl start tvwall
261 +
```
262 +
263 +
Bleibt es dabei, misst dieses Skript jede Variante einzeln durch und nennt die
264 +
verantwortliche Option — statt zu raten:
265 +
266 +
```bash
267 +
sudo /opt/tvwall/scripts/bisect-mpv.sh /var/lib/tvwall/media/<datei>
268 +
```
269 +
270 +
Was in der Datei steckt, zeigt `diagnose.sh` oder direkt:
271 +
272 +
```bash
273 +
ffprobe -hide_banner /var/lib/tvwall/media/<datei>
274 +
```
275 +
276 +
Ist das Material breiter als 1920 Pixel (Handyvideos sind oft 4K), hilft nur
277 +
Herunterrechnen — am besten auf dem Rechner, nicht auf dem Pi:
278 +
279 +
```bash
280 +
ffmpeg -i gross.mov -vf scale=1920:-2 -c:v libx264 -preset slow -crf 23 \
281 +
       -c:a aac -movflags +faststart klein.mp4
282 +
```
283 +
284 +
Die Wiedergabe bleibt dabei nicht hängen: Läuft ein Video `STALL_TIMEOUT`
285 +
Sekunden (Standard 30) nicht weiter, wird es übersprungen und die Schleife
286 +
läuft weiter. Im Webinterface steht dann, welche Datei betroffen war.
287 +
288 +
**Kein Ton.**
289 +
Ausgabegerät prüfen (`mpv --audio-device=help`) und passend in
290 +
`MPV_EXTRA_ARGS="--audio-device=..."` eintragen. Bei HDMI-Ton hilft oft
291 +
`--audio-device=alsa/sysdefault:CARD=vc4hdmi0`.
292 +
293 +
---
294 +
295 +
## Aufbau
296 +
297 +
```
298 +
cmd/tvwall/main.go        Start, Signalbehandlung, periodischer Ordner-Durchlauf
299 +
internal/config/          tvwall.conf lesen, Laufzeit-Einstellungen speichern
300 +
internal/playlist/        Ordner einlesen, Reihenfolge merken, playlist.m3u schreiben
301 +
internal/mpv/             JSON-IPC-Verbindung zu mpv
302 +
internal/player/          mpv starten, überwachen, Dateien einzeln weiterschalten
303 +
internal/web/             HTTP-Schnittstelle und Oberfläche (eingebettet)
304 +
systemd/                  Vorlagen für den Dienst (drm / desktop)
305 +
internal/player/display.go  Wayland-/X11-/DRM-Erkennung
306 +
scripts/setup-ap.sh       Access Point per nmcli
307 +
scripts/diagnose.sh       Selbsttest bei Problemen
308 +
scripts/bisect-mpv.sh     misst, welche mpv-Option einen Haenger ausloest
309 +
scripts/probe-transition.py  prueft den Uebergang Bild -> Video im selben mpv
310 +
install.sh                Installation auf dem Pi
311 +
```
312 +
313 +
Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
314 +
315 +
* **mpv spielt Bilder und Videos aus einer gemeinsamen Playliste.** Kein
316 +
  Umschalten zwischen zwei Programmen, keine Lücke am Übergang.
317 +
* **Die Playliste wird zwischen zwei Dateien getauscht**, nicht mittendrin: Ein
318 +
  Upload unterbricht kein laufendes Video. Wer nicht warten will, drückt
319 +
  „Neu laden“.
320 +
* **Die Oberfläche kommt ohne externe Bibliotheken aus** und ist ins Programm
321 +
  eingebettet. Am Access Point gibt es kein Internet — alles, was von einem CDN
322 +
  nachgeladen werden müsste, wäre dort tot.
323 +
* **Uploads landen erst in einer temporären Datei** und werden nach vollem
324 +
  Empfang umbenannt. So gerät keine halbe Datei in die Wiedergabe.
325 +
326 +
### Web-Schnittstelle
327 +
328 +
| Weg | Methode | Zweck |
329 +
|---|---|---|
330 +
| `/api/state` | GET | Playliste, Zustand, Einstellungen |
331 +
| `/api/upload` | POST | Dateien hochladen (multipart) |
332 +
| `/api/order` | POST | `{"order":["a.jpg","b.mp4"]}` |
333 +
| `/api/delete` | POST | `{"name":"a.jpg"}` |
334 +
| `/api/settings` | POST | `{"image_duration":5,"mute":false}` |
335 +
| `/api/control` | POST | `{"action":"next\|prev\|pause\|reload\|restart\|rescan"}` |
336 +
| `/thumb/<name>` | GET | Vorschaubild (per ffmpeg erzeugt, zwischengespeichert) |
337 +
| `/media/<name>` | GET | Originaldatei |
338 +
339 +
### Lokal ausprobieren
340 +
341 +
Ohne Pi, nur die Oberfläche (kein mpv nötig):
342 +
343 +
```bash
344 +
make run     # http://localhost:8080
345 +
```
hinzugefügt cmd/tvwall/main.go
+130 −0 Datei ansehen
@@ -0,0 +1,130 @@
1 +
// Kommando tvwall: Media-Player und Webinterface fuer die TV-Wall.
2 +
//
3 +
// Der Dienst liest regelmaessig den Medienordner ein, haelt daraus eine
4 +
// Playliste aktuell, laesst sie von mpv im Vollbild in Endlosschleife
5 +
// abspielen und stellt zum Hochladen und Sortieren ein Webinterface bereit.
6 +
package main
7 +
8 +
import (
9 +
	"context"
10 +
	"errors"
11 +
	"flag"
12 +
	"fmt"
13 +
	"log"
14 +
	"net/http"
15 +
	"os"
16 +
	"os/signal"
17 +
	"syscall"
18 +
	"time"
19 +
20 +
	"ehc/tvwall/internal/config"
21 +
	"ehc/tvwall/internal/player"
22 +
	"ehc/tvwall/internal/playlist"
23 +
	"ehc/tvwall/internal/web"
24 +
)
25 +
26 +
// version wird beim Bauen ueber -ldflags gesetzt.
27 +
var version = "dev"
28 +
29 +
func main() {
30 +
	var (
31 +
		configPath = flag.String("config", "/etc/tvwall/tvwall.conf", "Pfad zur Konfigurationsdatei")
32 +
		addr       = flag.String("addr", "", "Adresse des Webservers (ueberschreibt WEB_PORT)")
33 +
		stateDir   = flag.String("state-dir", "", "Datenverzeichnis (ueberschreibt STATE_DIR)")
34 +
		mpvSocket  = flag.String("mpv-socket", "", "Pfad des mpv-IPC-Sockets (ueberschreibt MPV_SOCKET)")
35 +
		noPlayer   = flag.Bool("no-player", false, "nur das Webinterface starten, kein mpv")
36 +
		showVer    = flag.Bool("version", false, "Version ausgeben und beenden")
37 +
	)
38 +
	flag.Parse()
39 +
40 +
	if *showVer {
41 +
		fmt.Printf("tvwall %s\n", version)
42 +
		return
43 +
	}
44 +
45 +
	log.SetFlags(log.Ldate | log.Ltime)
46 +
	log.Printf("tvwall %s startet", version)
47 +
48 +
	cfg, err := config.Load(*configPath)
49 +
	if err != nil {
50 +
		log.Fatalf("konfiguration %s: %v", *configPath, err)
51 +
	}
52 +
	if *stateDir != "" {
53 +
		cfg.StateDir = *stateDir
54 +
	}
55 +
	if *mpvSocket != "" {
56 +
		cfg.SocketPath = *mpvSocket
57 +
	}
58 +
	for _, dir := range []string{cfg.StateDir, cfg.MediaDir(), cfg.ThumbDir()} {
59 +
		if err := os.MkdirAll(dir, 0o775); err != nil {
60 +
			log.Fatalf("verzeichnis %s anlegen: %v", dir, err)
61 +
		}
62 +
	}
63 +
	log.Printf("medienordner: %s", cfg.MediaDir())
64 +
65 +
	settings := config.NewStore(cfg)
66 +
	pl := playlist.New(cfg)
67 +
	if _, err := pl.Scan(); err != nil {
68 +
		log.Printf("erster durchlauf des medienordners: %v", err)
69 +
	}
70 +
	log.Printf("playliste: %d eintraege", len(pl.Entries()))
71 +
72 +
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
73 +
	defer stop()
74 +
75 +
	pp := player.New(cfg, pl, settings)
76 +
	if *noPlayer {
77 +
		log.Printf("player deaktiviert (-no-player)")
78 +
	} else {
79 +
		go pp.Run(ctx)
80 +
	}
81 +
82 +
	go watchMedia(ctx, pl, time.Duration(cfg.ScanInterval)*time.Second)
83 +
84 +
	listen := *addr
85 +
	if listen == "" {
86 +
		listen = fmt.Sprintf(":%d", cfg.WebPort)
87 +
	}
88 +
	srv := web.New(cfg, pl, pp, settings)
89 +
90 +
	errCh := make(chan error, 1)
91 +
	go func() {
92 +
		if err := srv.ListenAndServe(listen); err != nil && !errors.Is(err, http.ErrServerClosed) {
93 +
			errCh <- err
94 +
		}
95 +
	}()
96 +
97 +
	select {
98 +
	case <-ctx.Done():
99 +
		log.Printf("beende …")
100 +
		// Dem Player einen Moment geben, mpv sauber zu beenden.
101 +
		time.Sleep(300 * time.Millisecond)
102 +
	case err := <-errCh:
103 +
		log.Fatalf("webserver: %v", err)
104 +
	}
105 +
}
106 +
107 +
// watchMedia liest den Medienordner regelmaessig ein. So landen auch Dateien
108 +
// in der Playliste, die per USB-Stick, SSH oder Netzwerkfreigabe ankommen.
109 +
func watchMedia(ctx context.Context, pl *playlist.Manager, every time.Duration) {
110 +
	if every < time.Second {
111 +
		every = 10 * time.Second
112 +
	}
113 +
	ticker := time.NewTicker(every)
114 +
	defer ticker.Stop()
115 +
	for {
116 +
		select {
117 +
		case <-ctx.Done():
118 +
			return
119 +
		case <-ticker.C:
120 +
			changed, err := pl.Scan()
121 +
			if err != nil {
122 +
				log.Printf("medienordner einlesen: %v", err)
123 +
				continue
124 +
			}
125 +
			if changed {
126 +
				log.Printf("medienordner geaendert: %d eintraege", len(pl.Entries()))
127 +
			}
128 +
		}
129 +
	}
130 +
}
geändert config/tvwall.conf
+73 −21 Datei ansehen
@@ -1,55 +1,107 @@
1 -
# ============================================================
1 +
# ============================================================================
2 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 -
# ============================================================
3 +
#
4 +
#  Installiert nach /etc/tvwall/tvwall.conf
5 +
#  Nach Aenderungen:   sudo systemctl restart tvwall
6 +
#  Nach Aenderung des WLAN-Namens zusaetzlich:
7 +
#                      sudo /opt/tvwall/scripts/setup-ap.sh
8 +
# ============================================================================
6 9
7 10
# --- WLAN Access Point ------------------------------------------------------
8 -
# Name des WLANs, mit dem man sich verbindet. MUSS pro Pi eindeutig sein!
11 +
12 +
# Name des WLANs, mit dem man sich verbindet.
13 +
# MUSS pro Pi eindeutig sein, damit man die Geraete unterscheiden kann.
9 14
AP_SSID="TVWall-01"
10 15
11 -
# WPA2-Passwort (mindestens 8 Zeichen)
16 +
# WPA2-Passwort, mindestens 8 Zeichen.
12 17
AP_PASSWORD="tvwall1234"
13 18
14 -
# Laendercode - wichtig, sonst bleibt das WLAN aus regulatorischen Gruenden aus
19 +
# Laendercode. Ohne passenden Code bleibt das WLAN aus rechtlichen Gruenden aus.
15 20
AP_COUNTRY="DE"
16 21
17 -
# IP-Adresse des Pi im eigenen WLAN. Das Webinterface laeuft auf dieser Adresse.
22 +
# Adresse des Pi im eigenen WLAN - unter dieser Adresse laeuft das Webinterface.
18 23
AP_IP="192.168.4.1"
19 24
20 -
# WLAN-Kanal (1-11 im 2.4-GHz-Band)
25 +
# WLAN-Kanal (1-11 im 2,4-GHz-Band).
21 26
AP_CHANNEL="7"
22 27
23 -
# Hostname des Pi. Erreichbar dann auch als http://<HOSTNAME>.local
28 +
# WLAN-Schnittstelle. Leer = automatisch erkennen (in der Regel wlan0).
29 +
AP_IFACE=""
30 +
31 +
# "yes" leitet jeden Namen auf den Pi um. Handys zeigen dann von selbst die
32 +
# Anmeldeseite an - manche trennen dafuer aber auch automatisch das WLAN.
33 +
CAPTIVE_PORTAL="no"
34 +
35 +
# Hostname des Pi. Erreichbar dann auch unter http://<HOSTNAME>.local
24 36
HOSTNAME="tvwall-01"
25 37
26 38
# --- Webinterface -----------------------------------------------------------
39 +
27 40
WEB_PORT="80"
28 41
29 -
# Optionales Passwort fuer das Webinterface. Leer = kein Login noetig.
42 +
# Passwortschutz fuer das Webinterface (Benutzername beliebig).
43 +
# Leer lassen = ohne Anmeldung erreichbar.
30 44
WEB_PASSWORD=""
31 45
32 -
# Maximale Uploadgroesse in MB
33 -
MAX_UPLOAD_MB="10048"
46 +
# Groesstmoeglicher Upload je Vorgang, in MB.
47 +
MAX_UPLOAD_MB="2048"
34 48
35 49
# --- Wiedergabe -------------------------------------------------------------
36 -
# Anzeigedauer fuer Bilder in Sekunden (spaeter auch im Webinterface aenderbar)
50 +
51 +
# Anzeigedauer fuer Bilder in Sekunden.
52 +
# Laesst sich spaeter auch im Webinterface umstellen.
37 53
IMAGE_DURATION="5"
38 54
55 +
# Tonausgabe ueberhaupt benutzen:  yes = ja,  no = mpv oeffnet kein Audiogeraet.
56 +
# "no" ist fuer eine reine Anzeigetafel der sicherere Weg: ein blockierendes
57 +
# oder falsch eingestelltes Audiogeraet kann sonst die Wiedergabe anhalten.
58 +
AUDIO="yes"
59 +
39 60
# Ton der Videos:  yes = stumm,  no = mit Ton
61 +
# (nur wirksam, wenn AUDIO="yes"; im Webinterface umschaltbar)
40 62
MUTE="no"
41 63
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"
64 +
# Wie oft der Medienordner nach neuen Dateien durchsucht wird (Sekunden).
65 +
SCAN_INTERVAL="10"
46 66
47 -
# Benutzer, unter dem der Player laeuft.
48 -
# Bei DISPLAY_MODE=desktop MUSS das der Desktop-Benutzer sein (meist "pi").
67 +
# Bildausgabe:
68 +
#   auto    = selbst erkennen (empfohlen)
69 +
#   drm     = direkt auf den Bildschirm, ohne Desktop (Raspberry Pi OS Lite)
70 +
#   desktop = in die laufende Desktop-Sitzung (labwc/wayfire/X11)
71 +
DISPLAY_MODE="auto"
72 +
73 +
# Benutzer, unter dem der Dienst laeuft.
74 +
# Wichtig: Laeuft ein Desktop, muss das der Benutzer sein, dem die
75 +
# Desktop-Sitzung gehoert (meist "pi") - nur der kommt an den Bildschirm.
76 +
# Bei DISPLAY_MODE="auto" erkennt install.sh das und traegt es hier ein.
49 77
RUN_USER="tvwall"
50 78
51 -
# Zusaetzliche mpv-Optionen (leer lassen, wenn unsicher)
79 +
# Hardwarebeschleunigte Videodekodierung.
80 +
#   no        = alles in Software dekodieren (Standard, mpvs eigene Vorgabe)
81 +
#   auto-safe = Hardware benutzen, wenn moeglich
82 +
# Der Raspberry Pi 5 hat KEINEN Hardware-Dekoder fuer H.264 - dort bleibt die
83 +
# Wiedergabe mit "auto-safe" nach ein bis zwei Sekunden stehen. Auf dem Pi 4
84 +
# kann "auto-safe" dagegen Rechenzeit sparen.
85 +
MPV_HWDEC="no"
86 +
87 +
# mpv vor jedem Video frisch starten.
88 +
# Auf manchen Systemen bricht ein Video ab, wenn mpv vorher schon ein Bild
89 +
# angezeigt hat ("partial file" im Protokoll). Ein frischer Prozess je Video
90 +
# umgeht das; der Preis ist ein kurzer schwarzer Moment davor.
91 +
# Mit scripts/probe-transition.py laesst sich pruefen, ob "no" reicht.
92 +
FRESH_MPV_FOR_VIDEO="yes"
93 +
94 +
# Laeuft ein Video so lange nicht weiter (Sekunden), wird es uebersprungen,
95 +
# damit eine einzelne Datei nicht die ganze Wiedergabe anhaelt. 0 = aus.
96 +
STALL_TIMEOUT="30"
97 +
98 +
# Zusaetzliche Optionen fuer mpv, z. B. --audio-device=... oder --video-rotate=90
52 99
MPV_EXTRA_ARGS=""
53 100
54 101
# --- Pfade ------------------------------------------------------------------
102 +
103 +
# Hier liegen Medien (Unterordner "media"), Playliste und Einstellungen.
55 104
STATE_DIR="/var/lib/tvwall"
105 +
106 +
# Steuersocket fuer mpv.
107 +
MPV_SOCKET="/run/tvwall/mpv.sock"
hinzugefügt install.sh
+340 −0 Datei ansehen
@@ -0,0 +1,340 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  TV-Wall - Installation auf dem Raspberry Pi (Raspberry Pi OS / Raspbian)
4 +
#
5 +
#  Aufruf:   sudo ./install.sh [Optionen]
6 +
#
7 +
#  Optionen:
8 +
#    --no-ap        WLAN-Access-Point nicht einrichten
9 +
#    --ap-only      nur den Access Point einrichten
10 +
#    --config PFAD  andere Konfigurationsdatei verwenden
11 +
#    --yes          keine Rueckfragen stellen
12 +
# ===========================================================================
13 +
set -euo pipefail
14 +
15 +
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16 +
CONFIG_DST="/etc/tvwall/tvwall.conf"
17 +
BIN_DST="/usr/local/bin/tvwall"
18 +
UNIT_DST="/etc/systemd/system/tvwall.service"
19 +
DOC_DST="/opt/tvwall"
20 +
21 +
WITH_AP=1
22 +
AP_ONLY=0
23 +
ASSUME_YES=0
24 +
CONFIG_SRC=""
25 +
26 +
info()  { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
27 +
ok()    { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
28 +
warn()  { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
29 +
die()   { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
30 +
31 +
while [[ $# -gt 0 ]]; do
32 +
  case "$1" in
33 +
    --no-ap)   WITH_AP=0; shift ;;
34 +
    --ap-only) AP_ONLY=1; shift ;;
35 +
    --yes|-y)  ASSUME_YES=1; shift ;;
36 +
    --config)  CONFIG_SRC="${2:-}"; shift 2 ;;
37 +
    -h|--help) sed -n '2,15p' "$0"; exit 0 ;;
38 +
    *)         die "unbekannte Option: $1" ;;
39 +
  esac
40 +
done
41 +
42 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten:  sudo $0"
43 +
44 +
# ---------------------------------------------------------------------------
45 +
# Konfiguration einlesen
46 +
# ---------------------------------------------------------------------------
47 +
if [[ -z "$CONFIG_SRC" ]]; then
48 +
  if [[ -f "$CONFIG_DST" ]]; then
49 +
    CONFIG_SRC="$CONFIG_DST"          # bestehende Installation: nicht ueberschreiben
50 +
  else
51 +
    CONFIG_SRC="$SRC_DIR/config/tvwall.conf"
52 +
  fi
53 +
fi
54 +
[[ -f "$CONFIG_SRC" ]] || die "Konfigurationsdatei nicht gefunden: $CONFIG_SRC"
55 +
56 +
set -a
57 +
# shellcheck disable=SC1090
58 +
. "$CONFIG_SRC"
59 +
set +a
60 +
61 +
AP_SSID="${AP_SSID:-TVWall-01}"
62 +
RUN_USER="${RUN_USER:-tvwall}"
63 +
STATE_DIR="${STATE_DIR:-/var/lib/tvwall}"
64 +
DISPLAY_MODE="${DISPLAY_MODE:-auto}"
65 +
HOSTNAME_NEW="${HOSTNAME:-tvwall-01}"
66 +
WEB_PORT="${WEB_PORT:-80}"
67 +
68 +
# --- Bildausgabe bestimmen --------------------------------------------------
69 +
# Der Desktop-Benutzer ist der einzige, der an die Sitzung herankommt.
70 +
detect_desktop_user() {
71 +
  local d u
72 +
  for d in /run/user/*; do
73 +
    [[ -d "$d" ]] || continue
74 +
    compgen -G "$d/wayland-*" >/dev/null 2>&1 || continue
75 +
    u="$(getent passwd "$(basename "$d")" | cut -d: -f1)"
76 +
    [[ -n "$u" ]] && { echo "$u"; return; }
77 +
  done
78 +
  u="$(sed -n 's/^autologin-user=//p' /etc/lightdm/lightdm.conf 2>/dev/null | head -1)"
79 +
  [[ -n "$u" ]] && { echo "$u"; return; }
80 +
  u="$(sed -n 's/.*--autologin \([^ ]*\).*/\1/p' \
81 +
       /etc/systemd/system/getty@tty1.service.d/autologin.conf 2>/dev/null | head -1)"
82 +
  [[ -n "$u" && "$u" != "root" ]] && { echo "$u"; return; }
83 +
  getent passwd 1000 | cut -d: -f1
84 +
}
85 +
86 +
GRAPHICAL=0
87 +
[[ "$(systemctl get-default 2>/dev/null)" == "graphical.target" ]] && GRAPHICAL=1
88 +
pgrep -x labwc  >/dev/null 2>&1 && GRAPHICAL=1
89 +
pgrep -x wayfire >/dev/null 2>&1 && GRAPHICAL=1
90 +
pgrep -x Xorg   >/dev/null 2>&1 && GRAPHICAL=1
91 +
92 +
DISPLAY_MODE_CHANGED=0
93 +
# Eine bestehende Konfiguration wird nicht ueberschrieben - deshalb kann hier
94 +
# noch ein "drm" aus einer frueheren Installation stehen. Auf einem System mit
95 +
# Desktop fuehrt das zwangslaeufig zum schwarzen Bildschirm.
96 +
if [[ "$DISPLAY_MODE" == "drm" && $GRAPHICAL -eq 1 ]]; then
97 +
  echo
98 +
  warn "In der Konfiguration steht DISPLAY_MODE=\"drm\", auf diesem Pi laeuft aber"
99 +
  warn "ein Desktop. mpv kann den Bildschirm so nicht bekommen - er bliebe schwarz."
100 +
  if [[ $ASSUME_YES -eq 1 ]]; then
101 +
    DISPLAY_MODE="auto"; DISPLAY_MODE_CHANGED=1
102 +
  else
103 +
    read -r -p "    Auf DISPLAY_MODE=\"auto\" umstellen? [J/n] " answer
104 +
    if [[ ! "$answer" =~ ^[nN]$ ]]; then
105 +
      DISPLAY_MODE="auto"; DISPLAY_MODE_CHANGED=1
106 +
    fi
107 +
  fi
108 +
  echo
109 +
fi
110 +
111 +
EFFECTIVE_MODE="$DISPLAY_MODE"
112 +
if [[ "$EFFECTIVE_MODE" == "auto" ]]; then
113 +
  if [[ $GRAPHICAL -eq 1 ]]; then
114 +
    EFFECTIVE_MODE="desktop"
115 +
  else
116 +
    EFFECTIVE_MODE="drm"
117 +
  fi
118 +
fi
119 +
120 +
RUN_USER_CHANGED=0
121 +
if [[ "$EFFECTIVE_MODE" == "desktop" && "$RUN_USER" == "tvwall" ]]; then
122 +
  DESKTOP_USER="$(detect_desktop_user || true)"
123 +
  if [[ -n "$DESKTOP_USER" ]]; then
124 +
    RUN_USER="$DESKTOP_USER"
125 +
    RUN_USER_CHANGED=1
126 +
  fi
127 +
fi
128 +
129 +
info "Konfiguration: $CONFIG_SRC"
130 +
echo "    WLAN-Name (SSID) : $AP_SSID"
131 +
echo "    Hostname         : $HOSTNAME_NEW"
132 +
if [[ "$DISPLAY_MODE" == "auto" ]]; then
133 +
  echo "    Bildausgabe      : auto -> $EFFECTIVE_MODE"
134 +
else
135 +
  echo "    Bildausgabe      : $EFFECTIVE_MODE"
136 +
fi
137 +
if [[ $RUN_USER_CHANGED -eq 1 ]]; then
138 +
  echo "    Benutzer         : $RUN_USER   (Desktop-Benutzer, automatisch erkannt)"
139 +
else
140 +
  echo "    Benutzer         : $RUN_USER"
141 +
fi
142 +
echo "    Medienordner     : $STATE_DIR/media"
143 +
echo
144 +
145 +
if [[ $AP_ONLY -eq 1 ]]; then
146 +
  exec "$SRC_DIR/scripts/setup-ap.sh" "$CONFIG_SRC"
147 +
fi
148 +
149 +
# ---------------------------------------------------------------------------
150 +
# Pakete
151 +
# ---------------------------------------------------------------------------
152 +
if command -v apt-get >/dev/null; then
153 +
  MISSING=()
154 +
  command -v mpv    >/dev/null || MISSING+=(mpv)
155 +
  command -v ffmpeg >/dev/null || MISSING+=(ffmpeg)
156 +
  if [[ ${#MISSING[@]} -gt 0 ]]; then
157 +
    info "Installiere Pakete: ${MISSING[*]}"
158 +
    DEBIAN_FRONTEND=noninteractive apt-get update -qq
159 +
    DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${MISSING[@]}"
160 +
  fi
161 +
  ok "mpv und ffmpeg vorhanden"
162 +
else
163 +
  warn "kein apt-get gefunden - mpv und ffmpeg bitte selbst installieren"
164 +
fi
165 +
166 +
# ---------------------------------------------------------------------------
167 +
# Programm installieren
168 +
# ---------------------------------------------------------------------------
169 +
info "Installiere Programm nach $BIN_DST"
170 +
ARCH="$(uname -m)"
171 +
case "$ARCH" in
172 +
  aarch64|arm64) PREBUILT="$SRC_DIR/build/tvwall-linux-arm64" ;;
173 +
  armv7l|armv6l) PREBUILT="$SRC_DIR/build/tvwall-linux-arm" ;;
174 +
  x86_64)        PREBUILT="$SRC_DIR/build/tvwall-linux-amd64" ;;
175 +
  *)             PREBUILT="" ;;
176 +
esac
177 +
178 +
if [[ -n "$PREBUILT" && -f "$PREBUILT" ]]; then
179 +
  install -m 0755 "$PREBUILT" "$BIN_DST"
180 +
  ok "fertiges Programm verwendet ($ARCH)"
181 +
elif command -v go >/dev/null && [[ -f "$SRC_DIR/go.mod" ]]; then
182 +
  info "Baue aus dem Quelltext (das dauert einen Moment)"
183 +
  ( cd "$SRC_DIR" && go build -trimpath -ldflags "-s -w" -o /tmp/tvwall.build ./cmd/tvwall )
184 +
  install -m 0755 /tmp/tvwall.build "$BIN_DST" && rm -f /tmp/tvwall.build
185 +
  ok "aus Quelltext gebaut"
186 +
elif [[ -x "$BIN_DST" ]]; then
187 +
  # Erneuter Aufruf aus /opt/tvwall, nur um die Konfiguration zu uebernehmen.
188 +
  ok "vorhandenes Programm beibehalten ($("$BIN_DST" -version 2>/dev/null || echo unbekannt))"
189 +
else
190 +
  die "kein passendes Programm gefunden ($ARCH) und Go ist nicht installiert.
191 +
       Auf dem Entwicklungsrechner 'make dist' ausfuehren und das Paket hierher kopieren."
192 +
fi
193 +
194 +
# ---------------------------------------------------------------------------
195 +
# Benutzer und Verzeichnisse
196 +
# ---------------------------------------------------------------------------
197 +
if ! id -u "$RUN_USER" >/dev/null 2>&1; then
198 +
  info "Lege Benutzer $RUN_USER an"
199 +
  useradd --system --create-home --home-dir "/home/$RUN_USER" --shell /usr/sbin/nologin "$RUN_USER"
200 +
fi
201 +
for grp in video render audio input tty; do
202 +
  getent group "$grp" >/dev/null && usermod -aG "$grp" "$RUN_USER" || true
203 +
done
204 +
ok "Benutzer $RUN_USER eingerichtet"
205 +
206 +
install -d -m 0775 -o "$RUN_USER" -g "$RUN_USER" "$STATE_DIR" "$STATE_DIR/media" "$STATE_DIR/thumbs"
207 +
chown -R "$RUN_USER":"$RUN_USER" "$STATE_DIR" 2>/dev/null || true
208 +
ok "Medienordner: $STATE_DIR/media"
209 +
210 +
install -d -m 0755 /etc/tvwall
211 +
if [[ -f "$CONFIG_DST" && "$CONFIG_SRC" != "$CONFIG_DST" ]]; then
212 +
  install -m 0644 "$CONFIG_SRC" "$CONFIG_DST.neu"
213 +
  warn "$CONFIG_DST existiert bereits - neue Fassung liegt als $CONFIG_DST.neu daneben"
214 +
elif [[ ! -f "$CONFIG_DST" ]]; then
215 +
  install -m 0644 "$CONFIG_SRC" "$CONFIG_DST"
216 +
  ok "Konfiguration nach $CONFIG_DST kopiert"
217 +
fi
218 +
219 +
if [[ $RUN_USER_CHANGED -eq 1 && -f "$CONFIG_DST" ]]; then
220 +
  sed -i "s|^RUN_USER=.*|RUN_USER=\"$RUN_USER\"|" "$CONFIG_DST"
221 +
  ok "RUN_USER in $CONFIG_DST auf '$RUN_USER' gesetzt (Desktop-Benutzer)"
222 +
fi
223 +
if [[ $DISPLAY_MODE_CHANGED -eq 1 && -f "$CONFIG_DST" ]]; then
224 +
  sed -i "s|^DISPLAY_MODE=.*|DISPLAY_MODE=\"auto\"|" "$CONFIG_DST"
225 +
  ok "DISPLAY_MODE in $CONFIG_DST auf 'auto' gesetzt"
226 +
fi
227 +
228 +
install -d -m 0755 "$DOC_DST"
229 +
[[ -f "$SRC_DIR/README.md" ]] && install -m 0644 "$SRC_DIR/README.md" "$DOC_DST/README.md"
230 +
if [[ -d "$SRC_DIR/scripts" ]]; then
231 +
  install -d -m 0755 "$DOC_DST/scripts"
232 +
  install -m 0755 "$SRC_DIR"/scripts/* "$DOC_DST/scripts/"
233 +
fi
234 +
# Installer und Vorlagen mitnehmen, damit spaetere Aenderungen ohne das
235 +
# Quellpaket auskommen:  sudo /opt/tvwall/install.sh --no-ap
236 +
if [[ "$SRC_DIR" != "$DOC_DST" ]]; then
237 +
  install -m 0755 "$SRC_DIR/install.sh" "$DOC_DST/install.sh"
238 +
  install -d -m 0755 "$DOC_DST/systemd" "$DOC_DST/config"
239 +
  install -m 0644 "$SRC_DIR"/systemd/*.in "$DOC_DST/systemd/"
240 +
  install -m 0644 "$SRC_DIR"/config/tvwall.conf "$DOC_DST/config/tvwall.conf"
241 +
fi
242 +
243 +
# ---------------------------------------------------------------------------
244 +
# systemd-Dienst
245 +
# ---------------------------------------------------------------------------
246 +
info "Richte systemd-Dienst ein"
247 +
if [[ "$EFFECTIVE_MODE" == "desktop" ]]; then
248 +
  TEMPLATE="$SRC_DIR/systemd/tvwall-desktop.service.in"
249 +
  RUN_UID="$(id -u "$RUN_USER")"
250 +
  if [[ "$RUN_USER" == "tvwall" ]]; then
251 +
    warn "Bildausgabe im Desktop, aber der Dienst laeuft als 'tvwall'."
252 +
    warn "Nur der Benutzer, dem die Desktop-Sitzung gehoert, kommt an den Bildschirm."
253 +
    warn "Bitte RUN_USER in $CONFIG_DST auf den Desktop-Benutzer setzen (meist 'pi')."
254 +
  fi
255 +
else
256 +
  TEMPLATE="$SRC_DIR/systemd/tvwall-drm.service.in"
257 +
  RUN_UID=""
258 +
fi
259 +
[[ -f "$TEMPLATE" ]] || die "Vorlage fehlt: $TEMPLATE"
260 +
261 +
sed -e "s|@RUN_USER@|$RUN_USER|g" \
262 +
    -e "s|@RUN_UID@|$RUN_UID|g" \
263 +
    -e "s|@CONFIG@|$CONFIG_DST|g" \
264 +
    "$TEMPLATE" > "$UNIT_DST"
265 +
chmod 0644 "$UNIT_DST"
266 +
267 +
if [[ "$EFFECTIVE_MODE" != "desktop" ]]; then
268 +
  # Auf der Konsole soll kein Login-Prompt um den Bildschirm streiten.
269 +
  systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true
270 +
else
271 +
  # Im Desktop-Betrieb wird tty1 gebraucht - eine frueher im Konsolenmodus
272 +
  # abgeschaltete Konsole hier wieder einschalten.
273 +
  if ! systemctl is-enabled --quiet getty@tty1.service 2>/dev/null; then
274 +
    systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true
275 +
    ok "Konsole auf tty1 wieder aktiviert"
276 +
  fi
277 +
fi
278 +
279 +
systemctl daemon-reload
280 +
systemctl enable tvwall.service >/dev/null
281 +
ok "Dienst aktiviert (startet ab jetzt bei jedem Einschalten mit)"
282 +
283 +
# ---------------------------------------------------------------------------
284 +
# Hostname
285 +
# ---------------------------------------------------------------------------
286 +
if [[ -n "$HOSTNAME_NEW" && "$(hostname)" != "$HOSTNAME_NEW" ]]; then
287 +
  info "Setze Hostname auf $HOSTNAME_NEW"
288 +
  hostnamectl set-hostname "$HOSTNAME_NEW" || true
289 +
  sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t$HOSTNAME_NEW/" /etc/hosts 2>/dev/null || true
290 +
  grep -q "^127.0.1.1" /etc/hosts || printf '127.0.1.1\t%s\n' "$HOSTNAME_NEW" >> /etc/hosts
291 +
fi
292 +
command -v avahi-daemon >/dev/null && systemctl enable --now avahi-daemon >/dev/null 2>&1 || true
293 +
294 +
# ---------------------------------------------------------------------------
295 +
# Access Point
296 +
# ---------------------------------------------------------------------------
297 +
if [[ $WITH_AP -eq 1 ]]; then
298 +
  if [[ $ASSUME_YES -eq 0 ]]; then
299 +
    echo
300 +
    warn "Der Access Point belegt das WLAN des Pi."
301 +
    warn "Bist du gerade per WLAN verbunden, bricht diese Verbindung ab."
302 +
    read -r -p "    Access Point '$AP_SSID' jetzt einrichten? [j/N] " answer
303 +
    [[ "$answer" =~ ^[jJyY]$ ]] || WITH_AP=0
304 +
  fi
305 +
fi
306 +
if [[ $WITH_AP -eq 1 ]]; then
307 +
  "$SRC_DIR/scripts/setup-ap.sh" "$CONFIG_DST" || warn "Access Point konnte nicht eingerichtet werden - siehe Meldungen oben"
308 +
else
309 +
  info "Access Point uebersprungen (spaeter: sudo $DOC_DST/scripts/setup-ap.sh)"
310 +
fi
311 +
312 +
# ---------------------------------------------------------------------------
313 +
# Start
314 +
# ---------------------------------------------------------------------------
315 +
info "Starte Dienst"
316 +
systemctl restart tvwall.service
317 +
sleep 2
318 +
if systemctl is-active --quiet tvwall.service; then
319 +
  ok "tvwall laeuft"
320 +
else
321 +
  warn "Dienst laeuft nicht - Protokoll ansehen mit:  journalctl -u tvwall -n 50"
322 +
fi
323 +
324 +
PORT_SUFFIX=""
325 +
[[ "$WEB_PORT" != "80" ]] && PORT_SUFFIX=":$WEB_PORT"
326 +
cat <<EOF
327 +
328 +
===========================================================================
329 +
 Fertig.
330 +
331 +
   WLAN verbinden mit :  $AP_SSID
332 +
   Webinterface       :  http://${AP_IP:-192.168.4.1}$PORT_SUFFIX
333 +
                         http://$HOSTNAME_NEW.local$PORT_SUFFIX
334 +
   Medienordner       :  $STATE_DIR/media
335 +
336 +
   Protokoll ansehen  :  journalctl -u tvwall -f
337 +
   Dienst neu starten :  sudo systemctl restart tvwall
338 +
   Konfiguration      :  sudo nano $CONFIG_DST
339 +
===========================================================================
340 +
EOF
geändert internal/config/config.go
+41 −7 Datei ansehen
@@ -29,12 +29,19 @@ type Config struct {
29 29
30 30
	ImageDuration float64
31 31
	Mute          bool
32 +
	Audio         bool // false = Tonausgabe komplett abschalten
32 33
33 -
	DisplayMode  string // "drm" oder "desktop"
34 -
	RunUser      string
35 -
	MpvExtraArgs string
34 +
	ScanInterval int // Sekunden zwischen zwei Durchlaeufen des Medienordners
36 35
37 -
	StateDir string
36 +
	DisplayMode   string // "auto", "drm" oder "desktop"
37 +
	RunUser       string
38 +
	MpvExtraArgs  string
39 +
	MpvHwdec      string
40 +
	FreshForVideo bool // mpv vor jedem Video neu starten
41 +
	StallTimeout  int  // Sekunden ohne Fortschritt, bevor ein Video uebersprungen wird
42 +
43 +
	StateDir   string
44 +
	SocketPath string
38 45
}
39 46
40 47
// Default liefert die Konfiguration mit allen Standardwerten.
@@ -49,9 +56,15 @@ func Default() *Config {
49 56
		WebPort:       80,
50 57
		MaxUploadMB:   2048,
51 58
		ImageDuration: 5,
52 -
		DisplayMode:   "drm",
59 +
		Audio:         true,
60 +
		ScanInterval:  10,
61 +
		DisplayMode:   "auto",
62 +
		MpvHwdec:      "no",
63 +
		FreshForVideo: true,
64 +
		StallTimeout:  30,
53 65
		RunUser:       "tvwall",
54 66
		StateDir:      "/var/lib/tvwall",
67 +
		SocketPath:    "/run/tvwall/mpv.sock",
55 68
	}
56 69
}
57 70
@@ -61,7 +74,7 @@ func (c *Config) ThumbDir() string { return filepath.Join(c.StateDir, "thumb
61 74
func (c *Config) OrderFile() string    { return filepath.Join(c.StateDir, "order.json") }
62 75
func (c *Config) PlaylistFile() string { return filepath.Join(c.StateDir, "playlist.m3u") }
63 76
func (c *Config) SettingsFile() string { return filepath.Join(c.StateDir, "settings.json") }
64 -
func (c *Config) MpvSocket() string    { return "/run/tvwall/mpv.sock" }
77 +
func (c *Config) MpvSocket() string    { return c.SocketPath }
65 78
66 79
// Load liest die Konfigurationsdatei im Shell-Stil (KEY="value").
67 80
// Eine fehlende Datei ist kein Fehler - dann gelten die Standardwerte.
@@ -120,14 +133,35 @@ func Load(path string) (*Config, error) {
120 133
			}
121 134
		case "MUTE":
122 135
			c.Mute = isTrue(val)
136 +
		case "AUDIO":
137 +
			c.Audio = isTrue(val)
138 +
		case "SCAN_INTERVAL":
139 +
			if n, err := strconv.Atoi(val); err == nil && n >= 1 {
140 +
				c.ScanInterval = n
141 +
			}
123 142
		case "DISPLAY_MODE":
124 -
			if val == "drm" || val == "desktop" {
143 +
			switch val {
144 +
			case "auto", "drm", "desktop":
125 145
				c.DisplayMode = val
126 146
			}
127 147
		case "RUN_USER":
128 148
			c.RunUser = val
129 149
		case "MPV_EXTRA_ARGS":
130 150
			c.MpvExtraArgs = val
151 +
		case "FRESH_MPV_FOR_VIDEO":
152 +
			c.FreshForVideo = isTrue(val)
153 +
		case "MPV_HWDEC":
154 +
			if val != "" {
155 +
				c.MpvHwdec = val
156 +
			}
157 +
		case "STALL_TIMEOUT":
158 +
			if n, err := strconv.Atoi(val); err == nil && n >= 0 {
159 +
				c.StallTimeout = n
160 +
			}
161 +
		case "MPV_SOCKET":
162 +
			if val != "" {
163 +
				c.SocketPath = val
164 +
			}
131 165
		case "STATE_DIR":
132 166
			if val != "" {
133 167
				c.StateDir = val
hinzugefügt internal/mpv/mpv.go
+243 −0 Datei ansehen
@@ -0,0 +1,243 @@
1 +
// Package mpv spricht ueber den JSON-IPC-Socket mit einem laufenden mpv.
2 +
package mpv
3 +
4 +
import (
5 +
	"bufio"
6 +
	"encoding/json"
7 +
	"errors"
8 +
	"fmt"
9 +
	"net"
10 +
	"sync"
11 +
	"time"
12 +
)
13 +
14 +
// ErrClosed wird geliefert, wenn die Verbindung nicht mehr steht.
15 +
var ErrClosed = errors.New("mpv: verbindung geschlossen")
16 +
17 +
// Event ist eine von mpv gesendete Statusmeldung.
18 +
type Event struct {
19 +
	Name  string          `json:"event"`
20 +
	ID    int             `json:"id"`
21 +
	Data  json.RawMessage `json:"data"`
22 +
	Name2 string          `json:"name"` // bei property-change: Name der Eigenschaft
23 +
24 +
	// Raw ist die vollstaendige Meldung - fuer Felder, die je nach Ereignis
25 +
	// unterschiedlich heissen (etwa "reason" bei end-file).
26 +
	Raw json.RawMessage `json:"-"`
27 +
}
28 +
29 +
type response struct {
30 +
	Error     string          `json:"error"`
31 +
	Data      json.RawMessage `json:"data"`
32 +
	RequestID int             `json:"request_id"`
33 +
}
34 +
35 +
// Client ist eine Verbindung zum IPC-Socket von mpv.
36 +
type Client struct {
37 +
	conn net.Conn
38 +
39 +
	mu      sync.Mutex
40 +
	nextID  int
41 +
	pending map[int]chan response
42 +
	closed  bool
43 +
44 +
	events chan Event
45 +
	done   chan struct{}
46 +
}
47 +
48 +
// Dial verbindet sich mit dem Socket und wartet dabei bis zu timeout, weil mpv
49 +
// den Socket erst kurz nach dem Start anlegt.
50 +
func Dial(socketPath string, timeout time.Duration) (*Client, error) {
51 +
	deadline := time.Now().Add(timeout)
52 +
	var lastErr error
53 +
	for {
54 +
		conn, err := net.Dial("unix", socketPath)
55 +
		if err == nil {
56 +
			c := &Client{
57 +
				conn:    conn,
58 +
				pending: make(map[int]chan response),
59 +
				events:  make(chan Event, 64),
60 +
				done:    make(chan struct{}),
61 +
			}
62 +
			go c.readLoop()
63 +
			return c, nil
64 +
		}
65 +
		lastErr = err
66 +
		if time.Now().After(deadline) {
67 +
			return nil, fmt.Errorf("mpv-socket %s nicht erreichbar: %w", socketPath, lastErr)
68 +
		}
69 +
		time.Sleep(150 * time.Millisecond)
70 +
	}
71 +
}
72 +
73 +
// Events liefert den Kanal mit den Statusmeldungen von mpv.
74 +
func (c *Client) Events() <-chan Event { return c.events }
75 +
76 +
// Done wird geschlossen, sobald die Verbindung abbricht.
77 +
func (c *Client) Done() <-chan struct{} { return c.done }
78 +
79 +
// Close beendet die Verbindung.
80 +
func (c *Client) Close() error {
81 +
	c.mu.Lock()
82 +
	if c.closed {
83 +
		c.mu.Unlock()
84 +
		return nil
85 +
	}
86 +
	c.closed = true
87 +
	c.mu.Unlock()
88 +
	return c.conn.Close()
89 +
}
90 +
91 +
func (c *Client) readLoop() {
92 +
	defer func() {
93 +
		c.mu.Lock()
94 +
		c.closed = true
95 +
		for id, ch := range c.pending {
96 +
			close(ch)
97 +
			delete(c.pending, id)
98 +
		}
99 +
		c.mu.Unlock()
100 +
		close(c.done)
101 +
		close(c.events)
102 +
	}()
103 +
104 +
	sc := bufio.NewScanner(c.conn)
105 +
	sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
106 +
	for sc.Scan() {
107 +
		line := sc.Bytes()
108 +
		if len(line) == 0 {
109 +
			continue
110 +
		}
111 +
		// Eine Zeile ist entweder ein Event oder die Antwort auf ein Kommando.
112 +
		var probe struct {
113 +
			Event     string `json:"event"`
114 +
			RequestID *int   `json:"request_id"`
115 +
		}
116 +
		if json.Unmarshal(line, &probe) != nil {
117 +
			continue
118 +
		}
119 +
		if probe.Event != "" {
120 +
			var ev Event
121 +
			if json.Unmarshal(line, &ev) == nil {
122 +
				ev.Raw = append(json.RawMessage(nil), line...)
123 +
				select {
124 +
				case c.events <- ev:
125 +
				default: // Ereignisse sind unkritisch: lieber verwerfen als blockieren
126 +
				}
127 +
			}
128 +
			continue
129 +
		}
130 +
		if probe.RequestID == nil {
131 +
			continue
132 +
		}
133 +
		var resp response
134 +
		if json.Unmarshal(line, &resp) != nil {
135 +
			continue
136 +
		}
137 +
		c.mu.Lock()
138 +
		ch, ok := c.pending[resp.RequestID]
139 +
		if ok {
140 +
			delete(c.pending, resp.RequestID)
141 +
		}
142 +
		c.mu.Unlock()
143 +
		if ok {
144 +
			ch <- resp
145 +
			close(ch)
146 +
		}
147 +
	}
148 +
}
149 +
150 +
// Command schickt ein Kommando an mpv und wartet auf die Antwort.
151 +
func (c *Client) Command(args ...any) (json.RawMessage, error) {
152 +
	c.mu.Lock()
153 +
	if c.closed {
154 +
		c.mu.Unlock()
155 +
		return nil, ErrClosed
156 +
	}
157 +
	c.nextID++
158 +
	id := c.nextID
159 +
	ch := make(chan response, 1)
160 +
	c.pending[id] = ch
161 +
	c.mu.Unlock()
162 +
163 +
	payload, err := json.Marshal(map[string]any{"command": args, "request_id": id})
164 +
	if err != nil {
165 +
		c.forget(id)
166 +
		return nil, err
167 +
	}
168 +
	if err := c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second)); err == nil {
169 +
		defer c.conn.SetWriteDeadline(time.Time{})
170 +
	}
171 +
	if _, err := c.conn.Write(append(payload, '\n')); err != nil {
172 +
		c.forget(id)
173 +
		return nil, err
174 +
	}
175 +
176 +
	select {
177 +
	case resp, ok := <-ch:
178 +
		if !ok {
179 +
			return nil, ErrClosed
180 +
		}
181 +
		if resp.Error != "" && resp.Error != "success" {
182 +
			return resp.Data, fmt.Errorf("mpv: %s", resp.Error)
183 +
		}
184 +
		return resp.Data, nil
185 +
	case <-time.After(5 * time.Second):
186 +
		c.forget(id)
187 +
		return nil, fmt.Errorf("mpv: zeitueberschreitung bei %v", args)
188 +
	}
189 +
}
190 +
191 +
func (c *Client) forget(id int) {
192 +
	c.mu.Lock()
193 +
	delete(c.pending, id)
194 +
	c.mu.Unlock()
195 +
}
196 +
197 +
// SetProperty setzt eine mpv-Eigenschaft.
198 +
func (c *Client) SetProperty(name string, value any) error {
199 +
	_, err := c.Command("set_property", name, value)
200 +
	return err
201 +
}
202 +
203 +
// GetString liest eine Eigenschaft als Zeichenkette.
204 +
func (c *Client) GetString(name string) (string, error) {
205 +
	data, err := c.Command("get_property", name)
206 +
	if err != nil {
207 +
		return "", err
208 +
	}
209 +
	var s string
210 +
	if json.Unmarshal(data, &s) != nil {
211 +
		return string(data), nil
212 +
	}
213 +
	return s, nil
214 +
}
215 +
216 +
// GetBool liest eine Eigenschaft als Wahrheitswert.
217 +
func (c *Client) GetBool(name string) (bool, error) {
218 +
	data, err := c.Command("get_property", name)
219 +
	if err != nil {
220 +
		return false, err
221 +
	}
222 +
	var b bool
223 +
	return b, json.Unmarshal(data, &b)
224 +
}
225 +
226 +
// GetInt liest eine Eigenschaft als Ganzzahl.
227 +
func (c *Client) GetInt(name string) (int, error) {
228 +
	data, err := c.Command("get_property", name)
229 +
	if err != nil {
230 +
		return 0, err
231 +
	}
232 +
	var n float64
233 +
	if err := json.Unmarshal(data, &n); err != nil {
234 +
		return 0, err
235 +
	}
236 +
	return int(n), nil
237 +
}
238 +
239 +
// ObserveProperty laesst mpv Aenderungen an einer Eigenschaft melden.
240 +
func (c *Client) ObserveProperty(id int, name string) error {
241 +
	_, err := c.Command("observe_property", id, name)
242 +
	return err
243 +
}
hinzugefügt internal/player/display.go
+171 −0 Datei ansehen
@@ -0,0 +1,171 @@
1 +
package player
2 +
3 +
import (
4 +
	"fmt"
5 +
	"log"
6 +
	"os"
7 +
	"os/user"
8 +
	"path/filepath"
9 +
	"sort"
10 +
	"strings"
11 +
)
12 +
13 +
// display beschreibt, wohin mpv sein Bild malen soll.
14 +
type display struct {
15 +
	mode string   // "drm", "wayland" oder "x11"
16 +
	env  []string // zusaetzliche Umgebungsvariablen fuer mpv
17 +
	note string   // Hinweis fuers Protokoll, wenn etwas nicht stimmt
18 +
}
19 +
20 +
// resolveDisplay ermittelt die Bildausgabe.
21 +
//
22 +
// DISPLAY_MODE="auto" (Standard) sucht zuerst nach einer laufenden
23 +
// Desktop-Sitzung des eigenen Benutzers und faellt sonst auf die direkte
24 +
// Ausgabe ueber DRM/KMS zurueck. "drm" und "desktop" erzwingen die jeweilige
25 +
// Variante.
26 +
//
27 +
// Bei "desktop" ohne erreichbare Sitzung wird ein Fehler geliefert statt auf
28 +
// DRM auszuweichen: direkt nach dem Einschalten steht die Sitzung oft noch
29 +
// nicht, und ein Ausweichen wuerde dem gerade startenden Desktop den
30 +
// Bildschirm wegnehmen. Der Player versucht es dann einfach spaeter erneut.
31 +
func resolveDisplay(mode string) (display, error) {
32 +
	switch mode {
33 +
	case "drm":
34 +
		d := display{mode: "drm"}
35 +
		if other := sessionHint(); other != "" {
36 +
			d.note = "DISPLAY_MODE=drm ist gesetzt, aber es laeuft ein Desktop. " +
37 +
				"mpv kann den Bildschirm so nicht bekommen. " + other
38 +
		}
39 +
		return d, nil
40 +
41 +
	case "desktop":
42 +
		if d := detectSession(); d.mode != "" {
43 +
			return d, nil
44 +
		}
45 +
		msg := "keine erreichbare Desktop-Sitzung gefunden (DISPLAY_MODE=desktop)"
46 +
		if hint := sessionHint(); hint != "" {
47 +
			msg += ". " + hint
48 +
		}
49 +
		return display{}, fmt.Errorf("%s", msg)
50 +
51 +
	default: // "auto" und alles Unbekannte
52 +
		if d := detectSession(); d.mode != "" {
53 +
			return d, nil
54 +
		}
55 +
		return display{mode: "drm", note: sessionHint()}, nil
56 +
	}
57 +
}
58 +
59 +
// detectSession sucht die Wayland- bzw. X11-Sitzung des eigenen Benutzers.
60 +
func detectSession() display {
61 +
	runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
62 +
	if runtimeDir == "" {
63 +
		runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
64 +
	}
65 +
	var env []string
66 +
	if os.Getenv("XDG_RUNTIME_DIR") == "" {
67 +
		env = append(env, "XDG_RUNTIME_DIR="+runtimeDir)
68 +
	}
69 +
70 +
	// Wayland: labwc, wayfire & Co. legen den Socket im Laufzeitverzeichnis ab.
71 +
	// Je nach System heisst er wayland-0, wayland-1 - deshalb wird gesucht
72 +
	// statt geraten.
73 +
	if name := os.Getenv("WAYLAND_DISPLAY"); name != "" && socketExists(runtimeDir, name) {
74 +
		return display{mode: "wayland", env: env}
75 +
	}
76 +
	if name := findWaylandSocket(runtimeDir); name != "" {
77 +
		return display{mode: "wayland", env: append(env, "WAYLAND_DISPLAY="+name)}
78 +
	}
79 +
80 +
	// X11
81 +
	if d := os.Getenv("DISPLAY"); d != "" {
82 +
		return display{mode: "x11", env: env}
83 +
	}
84 +
	if names, _ := filepath.Glob("/tmp/.X11-unix/X*"); len(names) > 0 {
85 +
		sort.Strings(names)
86 +
		num := strings.TrimPrefix(filepath.Base(names[0]), "X")
87 +
		return display{mode: "x11", env: append(env, "DISPLAY=:"+num)}
88 +
	}
89 +
90 +
	return display{}
91 +
}
92 +
93 +
func socketExists(dir, name string) bool {
94 +
	if filepath.IsAbs(name) {
95 +
		_, err := os.Stat(name)
96 +
		return err == nil
97 +
	}
98 +
	_, err := os.Stat(filepath.Join(dir, name))
99 +
	return err == nil
100 +
}
101 +
102 +
func findWaylandSocket(dir string) string {
103 +
	matches, err := filepath.Glob(filepath.Join(dir, "wayland-*"))
104 +
	if err != nil {
105 +
		return ""
106 +
	}
107 +
	sort.Strings(matches)
108 +
	for _, m := range matches {
109 +
		if strings.HasSuffix(m, ".lock") {
110 +
			continue
111 +
		}
112 +
		if info, err := os.Stat(m); err == nil && info.Mode()&os.ModeSocket != 0 {
113 +
			return filepath.Base(m)
114 +
		}
115 +
	}
116 +
	return ""
117 +
}
118 +
119 +
// sessionHint schaut nach, ob ein ANDERER Benutzer einen Desktop laufen hat.
120 +
// Das ist der haeufigste Stolperstein: der Dienst laeuft als "tvwall", der
121 +
// Desktop gehoert aber "pi" - und auf dessen Sitzung kommt niemand sonst.
122 +
func sessionHint() string {
123 +
	me := os.Getuid()
124 +
	dirs, err := os.ReadDir("/run/user")
125 +
	if err != nil {
126 +
		return ""
127 +
	}
128 +
	for _, d := range dirs {
129 +
		if !d.IsDir() || d.Name() == fmt.Sprint(me) {
130 +
			continue
131 +
		}
132 +
		sockets, _ := filepath.Glob(filepath.Join("/run/user", d.Name(), "wayland-*"))
133 +
		if len(sockets) == 0 {
134 +
			continue
135 +
		}
136 +
		name := d.Name()
137 +
		if u, err := user.LookupId(d.Name()); err == nil {
138 +
			name = u.Username
139 +
		}
140 +
		return fmt.Sprintf(
141 +
			"Es laeuft eine Desktop-Sitzung des Benutzers %q, auf die dieser Dienst "+
142 +
				"keinen Zugriff hat. In /etc/tvwall/tvwall.conf setzen: "+
143 +
				"RUN_USER=%q und DISPLAY_MODE=\"desktop\", danach "+
144 +
				"'sudo /opt/tvwall/install.sh --no-ap'.", name, name)
145 +
	}
146 +
	return ""
147 +
}
148 +
149 +
// logDisplay schreibt die getroffene Wahl ins Protokoll.
150 +
func (d display) logDisplay() {
151 +
	switch d.mode {
152 +
	case "wayland":
153 +
		log.Printf("player: Bildausgabe ueber Wayland (%s)", envValue(d.env, "WAYLAND_DISPLAY", os.Getenv("WAYLAND_DISPLAY")))
154 +
	case "x11":
155 +
		log.Printf("player: Bildausgabe ueber X11 (%s)", envValue(d.env, "DISPLAY", os.Getenv("DISPLAY")))
156 +
	default:
157 +
		log.Printf("player: Bildausgabe direkt ueber DRM/KMS (ohne Desktop)")
158 +
	}
159 +
	if d.note != "" {
160 +
		log.Printf("player: HINWEIS: %s", d.note)
161 +
	}
162 +
}
163 +
164 +
func envValue(env []string, key, fallback string) string {
165 +
	for _, e := range env {
166 +
		if strings.HasPrefix(e, key+"=") {
167 +
			return strings.TrimPrefix(e, key+"=")
168 +
		}
169 +
	}
170 +
	return fallback
171 +
}
hinzugefügt internal/player/player.go
+643 −0 Datei ansehen
@@ -0,0 +1,643 @@
1 +
// Package player startet und ueberwacht mpv und steuert die Wiedergabe.
2 +
//
3 +
// mpv bekommt bewusst KEINE Playliste, sondern immer nur die eine Datei, die
4 +
// gerade laufen soll (loadfile ... replace). Die Reihenfolge fuehrt dieses
5 +
// Programm selbst. Grund: eine gemischte Playliste aus Bildern und Videos
6 +
// bringt den Demuxer durcheinander - eine Videodatei, die allein einwandfrei
7 +
// laeuft, bricht als zweiter Eintrag nach kurzer Zeit mit "partial file" ab.
8 +
// Als jeweils einzige geladene Datei tritt das nicht auf; nebenbei haben wir
9 +
// so die volle Kontrolle ueber Reihenfolge, Weiterschalten und Fehlerfaelle.
10 +
package player
11 +
12 +
import (
13 +
	"context"
14 +
	"encoding/json"
15 +
	"errors"
16 +
	"fmt"
17 +
	"log"
18 +
	"os"
19 +
	"os/exec"
20 +
	"path/filepath"
21 +
	"strconv"
22 +
	"strings"
23 +
	"sync"
24 +
	"time"
25 +
26 +
	"ehc/tvwall/internal/config"
27 +
	"ehc/tvwall/internal/mpv"
28 +
	"ehc/tvwall/internal/playlist"
29 +
)
30 +
31 +
// IDs fuer die beobachteten mpv-Eigenschaften.
32 +
const (
33 +
	propPath = iota + 1
34 +
	propPause
35 +
	propIdle
36 +
	propTimePos
37 +
)
38 +
39 +
// Takt der Ueberwachung: haengende Dateien und leerer Bildschirm.
40 +
const tickInterval = 5 * time.Second
41 +
42 +
// Wartezeit, bevor nach lauter fehlerhaften Dateien erneut versucht wird.
43 +
const retryDelay = 15 * time.Second
44 +
45 +
// errFreshMpv ist kein Fehler, sondern der Wunsch nach einem frischen
46 +
// mpv-Prozess fuer die naechste Datei. Siehe FRESH_MPV_FOR_VIDEO.
47 +
var errFreshMpv = errors.New("frischer mpv-prozess fuer das naechste video")
48 +
49 +
// Status beschreibt den Zustand der Wiedergabe fuer das Webinterface.
50 +
type Status struct {
51 +
	Running       bool   `json:"running"`
52 +
	Idle          bool   `json:"idle"`
53 +
	Paused        bool   `json:"paused"`
54 +
	Current       string `json:"current"`
55 +
	Position      int    `json:"position"`
56 +
	Display       string `json:"display"`
57 +
	PendingReload bool   `json:"pending_reload"`
58 +
	LastError     string `json:"last_error"`
59 +
}
60 +
61 +
// Player haelt genau einen mpv-Prozess am Leben und schaltet die Dateien weiter.
62 +
type Player struct {
63 +
	cfg      *config.Config
64 +
	pl       *playlist.Manager
65 +
	settings *config.Store
66 +
67 +
	mu     sync.RWMutex
68 +
	client *mpv.Client
69 +
	status Status
70 +
71 +
	playing     string    // Datei, die wir mpv zuletzt uebergeben haben
72 +
	index       int       // deren Platz in der Playliste
73 +
	errStreak   int       // Dateien in Folge, die sich nicht abspielen liessen
74 +
	lastAttempt time.Time // wann zuletzt eine Datei geladen wurde
75 +
76 +
	lastTimePos    float64
77 +
	lastProgressAt time.Time
78 +
79 +
	loadedCount int // Dateien, die dieser mpv-Prozess schon geladen hat
80 +
81 +
	advance   chan int
82 +
	reloadNow chan struct{}
83 +
	restart   chan struct{}
84 +
}
85 +
86 +
// New erzeugt den Player.
87 +
func New(cfg *config.Config, pl *playlist.Manager, settings *config.Store) *Player {
88 +
	return &Player{
89 +
		cfg:       cfg,
90 +
		pl:        pl,
91 +
		settings:  settings,
92 +
		advance:   make(chan int, 4),
93 +
		reloadNow: make(chan struct{}, 1),
94 +
		restart:   make(chan struct{}, 1),
95 +
	}
96 +
}
97 +
98 +
// Status liefert den aktuellen Zustand.
99 +
func (p *Player) Status() Status {
100 +
	p.mu.RLock()
101 +
	defer p.mu.RUnlock()
102 +
	return p.status
103 +
}
104 +
105 +
// Run laeuft bis ctx abgebrochen wird und startet mpv bei Bedarf neu.
106 +
func (p *Player) Run(ctx context.Context) {
107 +
	settingsChanged := p.settings.Subscribe()
108 +
	backoff := time.Second
109 +
	for ctx.Err() == nil {
110 +
		start := time.Now()
111 +
		err := p.runOnce(ctx, settingsChanged)
112 +
		p.setRunning(false)
113 +
		if ctx.Err() != nil {
114 +
			return
115 +
		}
116 +
		if errors.Is(err, errFreshMpv) {
117 +
			// Geplanter, kurzer Neustart - kein Grund zu warten.
118 +
			continue
119 +
		}
120 +
		if err != nil {
121 +
			log.Printf("player: %v", err)
122 +
			p.setError(err.Error())
123 +
		}
124 +
		if time.Since(start) > time.Minute {
125 +
			backoff = time.Second
126 +
		} else if backoff < 15*time.Second {
127 +
			backoff *= 2
128 +
		}
129 +
		log.Printf("player: neustart in %s", backoff)
130 +
		select {
131 +
		case <-ctx.Done():
132 +
			return
133 +
		case <-time.After(backoff):
134 +
		}
135 +
	}
136 +
}
137 +
138 +
// runOnce startet mpv, verbindet sich mit dem IPC-Socket und bleibt, bis mpv
139 +
// endet oder ctx abgebrochen wird.
140 +
func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) error {
141 +
	disp, err := resolveDisplay(p.cfg.DisplayMode)
142 +
	if err != nil {
143 +
		return err
144 +
	}
145 +
	disp.logDisplay()
146 +
147 +
	sock := p.cfg.MpvSocket()
148 +
	if err := os.MkdirAll(filepath.Dir(sock), 0o775); err != nil {
149 +
		return fmt.Errorf("socket-verzeichnis: %w", err)
150 +
	}
151 +
	_ = os.Remove(sock)
152 +
153 +
	args := p.mpvArgs(disp)
154 +
	log.Printf("player: starte mpv %s", strings.Join(args, " "))
155 +
156 +
	cmd := exec.Command("mpv", args...)
157 +
	// stdin durchreichen: bei --vo=gpu --gpu-context=drm braucht mpv das
158 +
	// Terminal der Konsole (tty1), um den Bildschirm uebernehmen zu duerfen.
159 +
	cmd.Stdin = os.Stdin
160 +
	cmd.Stdout = os.Stdout
161 +
	cmd.Stderr = os.Stderr
162 +
	cmd.Env = append(os.Environ(), disp.env...)
163 +
	if err := cmd.Start(); err != nil {
164 +
		return fmt.Errorf("mpv starten: %w (ist mpv installiert?)", err)
165 +
	}
166 +
167 +
	var waitErr error
168 +
	exited := make(chan struct{})
169 +
	go func() {
170 +
		waitErr = cmd.Wait()
171 +
		close(exited)
172 +
	}()
173 +
174 +
	defer func() {
175 +
		if cmd.Process != nil {
176 +
			_ = cmd.Process.Kill()
177 +
		}
178 +
		<-exited
179 +
		_ = os.Remove(sock)
180 +
	}()
181 +
182 +
	client, err := mpv.Dial(sock, 15*time.Second)
183 +
	if err != nil {
184 +
		return err
185 +
	}
186 +
	defer client.Close()
187 +
188 +
	p.mu.Lock()
189 +
	p.client = client
190 +
	p.status = Status{Running: true, Display: disp.mode}
191 +
	p.index = 0
192 +
	p.errStreak = 0
193 +
	p.loadedCount = 0
194 +
	p.mu.Unlock()
195 +
	defer func() {
196 +
		p.mu.Lock()
197 +
		p.client = nil
198 +
		p.mu.Unlock()
199 +
	}()
200 +
201 +
	for id, name := range map[int]string{
202 +
		propPath: "path", propPause: "pause",
203 +
		propIdle: "idle-active", propTimePos: "time-pos",
204 +
	} {
205 +
		if err := client.ObserveProperty(id, name); err != nil {
206 +
			log.Printf("player: observe %s: %v", name, err)
207 +
		}
208 +
	}
209 +
210 +
	p.applySettings(client)
211 +
	if err := p.step(client, 0); err != nil { // erste Datei laden
212 +
		return err
213 +
	}
214 +
	p.setError("")
215 +
216 +
	ticker := time.NewTicker(tickInterval)
217 +
	defer ticker.Stop()
218 +
219 +
	for {
220 +
		select {
221 +
		case <-ctx.Done():
222 +
			return nil
223 +
224 +
		case <-exited:
225 +
			return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)
226 +
227 +
		case <-client.Done():
228 +
			select {
229 +
			case <-exited:
230 +
				return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)
231 +
			case <-time.After(time.Second):
232 +
				return fmt.Errorf("ipc-verbindung verloren")
233 +
			}
234 +
235 +
		case <-p.pl.Changed():
236 +
			// Aenderungen greifen von selbst beim naechsten Dateiwechsel, weil
237 +
			// dort die Playliste neu gelesen wird. Nur wenn gerade nichts
238 +
			// laeuft, muss die Wiedergabe angestossen werden.
239 +
			p.mu.RLock()
240 +
			idleNow := p.playing == ""
241 +
			p.mu.RUnlock()
242 +
			if idleNow {
243 +
				if err := p.step(client, 0); err != nil {
244 +
					return err
245 +
				}
246 +
			}
247 +
248 +
		case delta := <-p.advance:
249 +
			if err := p.step(client, delta); err != nil {
250 +
				return err
251 +
			}
252 +
253 +
		case <-p.reloadNow:
254 +
			p.mu.Lock()
255 +
			p.playing = ""
256 +
			p.index = 0
257 +
			p.errStreak = 0
258 +
			p.mu.Unlock()
259 +
			if err := p.step(client, 0); err != nil {
260 +
				return err
261 +
			}
262 +
263 +
		case <-p.restart:
264 +
			return fmt.Errorf("neustart angefordert")
265 +
266 +
		case <-settingsChanged:
267 +
			p.applySettings(client)
268 +
269 +
		case <-ticker.C:
270 +
			if err := p.tick(client); err != nil {
271 +
				return err
272 +
			}
273 +
274 +
		case ev, ok := <-client.Events():
275 +
			if !ok {
276 +
				return fmt.Errorf("ipc-verbindung beendet")
277 +
			}
278 +
			if err := p.handleEvent(client, ev); err != nil {
279 +
				return err
280 +
			}
281 +
		}
282 +
	}
283 +
}
284 +
285 +
func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) error {
286 +
	switch ev.Name {
287 +
	case "property-change":
288 +
		p.mu.Lock()
289 +
		switch ev.ID {
290 +
		case propPath:
291 +
			var s string
292 +
			if json.Unmarshal(ev.Data, &s) == nil && s != "" {
293 +
				p.status.Current = filepath.Base(s)
294 +
				// Eine Datei laeuft wieder - die Fehlerkette ist unterbrochen.
295 +
				p.errStreak = 0
296 +
			} else {
297 +
				p.status.Current = ""
298 +
			}
299 +
			p.lastTimePos = -1
300 +
			p.lastProgressAt = time.Now()
301 +
		case propPause:
302 +
			var b bool
303 +
			if json.Unmarshal(ev.Data, &b) == nil {
304 +
				p.status.Paused = b
305 +
			}
306 +
		case propIdle:
307 +
			var b bool
308 +
			if json.Unmarshal(ev.Data, &b) == nil {
309 +
				p.status.Idle = b
310 +
			}
311 +
		case propTimePos:
312 +
			var pos float64
313 +
			if json.Unmarshal(ev.Data, &pos) == nil && pos != p.lastTimePos {
314 +
				p.lastTimePos = pos
315 +
				p.lastProgressAt = time.Now()
316 +
			}
317 +
		}
318 +
		p.mu.Unlock()
319 +
320 +
	case "end-file":
321 +
		// "stop" kommt von unserem eigenen loadfile - das darf nicht
322 +
		// weiterschalten, sonst wird jede zweite Datei uebersprungen.
323 +
		var e struct {
324 +
			Reason    string `json:"reason"`
325 +
			FileError string `json:"file_error"`
326 +
		}
327 +
		_ = json.Unmarshal(ev.Raw, &e)
328 +
		switch e.Reason {
329 +
		case "eof":
330 +
			return p.step(client, 1)
331 +
		case "error":
332 +
			p.mu.Lock()
333 +
			bad := p.playing
334 +
			p.errStreak++
335 +
			p.mu.Unlock()
336 +
			log.Printf("player: %q liess sich nicht abspielen (%s) - wird uebersprungen",
337 +
				bad, strings.TrimSpace(e.FileError))
338 +
			p.setError(fmt.Sprintf("%s liess sich nicht abspielen", bad))
339 +
			return p.step(client, 1)
340 +
		}
341 +
	}
342 +
	return nil
343 +
}
344 +
345 +
// step schaltet um delta Eintraege weiter und laedt die Datei in mpv.
346 +
// delta 0 laedt den aktuellen Eintrag (bzw. steigt neu ein).
347 +
func (p *Player) step(client *mpv.Client, delta int) error {
348 +
	entries, _ := p.pl.EntriesWithVersion()
349 +
	if len(entries) == 0 {
350 +
		p.mu.Lock()
351 +
		p.playing, p.index = "", 0
352 +
		p.status.Current = ""
353 +
		p.mu.Unlock()
354 +
		if _, err := client.Command("stop"); err != nil {
355 +
			log.Printf("player: stop: %v", err)
356 +
		}
357 +
		return nil
358 +
	}
359 +
360 +
	p.mu.RLock()
361 +
	playing, index, errStreak := p.playing, p.index, p.errStreak
362 +
	loaded := p.loadedCount
363 +
	p.mu.RUnlock()
364 +
365 +
	// Lieber gar nichts tun als in einer Schleife lauter kaputte Dateien
366 +
	// durchzuhecheln - der naechste Takt versucht es in Ruhe erneut.
367 +
	if errStreak >= len(entries) {
368 +
		log.Printf("player: keine der %d Dateien liess sich abspielen - warte %s",
369 +
			len(entries), retryDelay)
370 +
		p.setError("keine abspielbare Datei in der Playliste")
371 +
		p.mu.Lock()
372 +
		p.playing = ""
373 +
		p.lastAttempt = time.Now()
374 +
		p.mu.Unlock()
375 +
		return nil
376 +
	}
377 +
378 +
	// Position anhand des Dateinamens bestimmen: so bleibt die Reihenfolge
379 +
	// auch dann sinnvoll, wenn zwischendurch etwas hinzugekommen oder
380 +
	// weggefallen ist.
381 +
	base := index
382 +
	if playing != "" {
383 +
		if i := indexOf(entries, playing); i >= 0 {
384 +
			base = i
385 +
		}
386 +
	}
387 +
	next := ((base+delta)%len(entries) + len(entries)) % len(entries)
388 +
389 +
	entry := entries[next]
390 +
	path := filepath.Join(p.pl.MediaDir(), entry.Name)
391 +
392 +
	// Ein Video in einen mpv-Prozess zu laden, der vorher schon etwas anderes
393 +
	// angezeigt hat, endet auf manchen Systemen im Abbruch des Demuxers
394 +
	// ("partial file"). Deshalb bekommt jedes Video einen frischen Prozess.
395 +
	if p.cfg.FreshForVideo && entry.Kind == "video" && loaded > 0 {
396 +
		p.mu.Lock()
397 +
		p.playing = entry.Name // damit der neue Prozess genau hier einsteigt
398 +
		p.index = next
399 +
		p.mu.Unlock()
400 +
		log.Printf("player: starte mpv neu fuer %q", entry.Name)
401 +
		return errFreshMpv
402 +
	}
403 +
404 +
	p.mu.Lock()
405 +
	p.playing = entry.Name
406 +
	p.index = next
407 +
	p.lastAttempt = time.Now()
408 +
	p.lastTimePos = -1
409 +
	p.lastProgressAt = time.Now()
410 +
	p.status.Position = next
411 +
	p.loadedCount++
412 +
	p.mu.Unlock()
413 +
414 +
	if _, err := client.Command("loadfile", path, "replace"); err != nil {
415 +
		log.Printf("player: %q laden: %v", entry.Name, err)
416 +
		p.setError("Laden von " + entry.Name + ": " + err.Error())
417 +
		p.mu.Lock()
418 +
		p.errStreak++
419 +
		p.mu.Unlock()
420 +
		return nil
421 +
	}
422 +
	if err := client.SetProperty("pause", false); err != nil {
423 +
		log.Printf("player: fortsetzen: %v", err)
424 +
	}
425 +
	return nil
426 +
}
427 +
428 +
func indexOf(entries []playlist.Entry, name string) int {
429 +
	for i, e := range entries {
430 +
		if e.Name == name {
431 +
			return i
432 +
		}
433 +
	}
434 +
	return -1
435 +
}
436 +
437 +
// tick prueft regelmaessig, ob die Wiedergabe noch laeuft.
438 +
func (p *Player) tick(client *mpv.Client) error {
439 +
	p.mu.RLock()
440 +
	playing, paused := p.playing, p.status.Paused
441 +
	current := p.status.Current
442 +
	since := time.Since(p.lastProgressAt)
443 +
	sinceAttempt := time.Since(p.lastAttempt)
444 +
	p.mu.RUnlock()
445 +
446 +
	// Nichts geladen (leere Playliste oder lauter kaputte Dateien):
447 +
	// nach einer Weile erneut versuchen.
448 +
	if playing == "" {
449 +
		if len(p.pl.Entries()) > 0 && sinceAttempt > retryDelay {
450 +
			p.mu.Lock()
451 +
			p.errStreak = 0
452 +
			p.mu.Unlock()
453 +
			return p.step(client, 0)
454 +
		}
455 +
		return nil
456 +
	}
457 +
458 +
	// Haengendes Video: ueberspringen, damit nicht die ganze Wand steht.
459 +
	timeout := time.Duration(p.cfg.StallTimeout) * time.Second
460 +
	if timeout <= 0 || paused || current == "" || playlist.Kind(current) != "video" || since < timeout {
461 +
		return nil
462 +
	}
463 +
464 +
	log.Printf("player: %q laeuft seit %s nicht weiter - wird uebersprungen",
465 +
		current, since.Round(time.Second))
466 +
	p.setError(fmt.Sprintf("%s bleibt haengen und wurde uebersprungen", current))
467 +
	p.mu.Lock()
468 +
	p.lastProgressAt = time.Now()
469 +
	p.mu.Unlock()
470 +
471 +
	// Reagiert mpv nicht mehr, hilft nur noch ein Neustart des Prozesses.
472 +
	if _, err := client.Command("playlist-remove", "current"); err != nil {
473 +
		// playlist-remove ist nur ein billiger Lebenstest; scheitert er,
474 +
		// steht mpv wirklich.
475 +
		return fmt.Errorf("mpv reagiert nicht mehr: %w", err)
476 +
	}
477 +
	return p.step(client, 1)
478 +
}
479 +
480 +
// applySettings uebernimmt Anzeigedauer und Stummschaltung ohne mpv-Neustart.
481 +
func (p *Player) applySettings(client *mpv.Client) {
482 +
	s := p.settings.Get()
483 +
	if err := client.SetProperty("image-display-duration", s.ImageDuration); err != nil {
484 +
		log.Printf("player: anzeigedauer setzen: %v", err)
485 +
	}
486 +
	if p.cfg.Audio {
487 +
		if err := client.SetProperty("mute", s.Mute); err != nil {
488 +
			log.Printf("player: stummschaltung setzen: %v", err)
489 +
		}
490 +
	}
491 +
}
492 +
493 +
func (p *Player) mpvArgs(disp display) []string {
494 +
	s := p.settings.Get()
495 +
	args := []string{
496 +
		"--input-ipc-server=" + p.cfg.MpvSocket(),
497 +
		"--fullscreen",
498 +
		"--idle=yes",
499 +
		"--force-window=yes",
500 +
		"--keep-open=no",
501 +
		"--image-display-duration=" + strconv.FormatFloat(s.ImageDuration, 'f', -1, 64),
502 +
		"--hwdec=" + p.cfg.MpvHwdec,
503 +
		"--no-osc",
504 +
		"--osd-level=0",
505 +
		"--cursor-autohide=always",
506 +
		"--no-input-default-bindings",
507 +
		"--input-vo-keyboard=no",
508 +
		"--stop-screensaver=yes",
509 +
		// Kein --no-terminal: sonst verschluckt mpv seine eigenen
510 +
		// Fehlermeldungen, und im Journal steht nur, dass es beendet wurde.
511 +
		"--msg-level=all=warn",
512 +
		"--no-input-terminal",
513 +
	}
514 +
	if p.cfg.Audio {
515 +
		args = append(args, "--mute="+yesNo(s.Mute))
516 +
	} else {
517 +
		// Ohne Tonausgabe kann auch kein blockierendes Audiogeraet die
518 +
		// Wiedergabe anhalten - fuer eine reine Anzeigetafel der sichere Weg.
519 +
		args = append(args, "--no-audio")
520 +
	}
521 +
	if disp.mode == "drm" {
522 +
		args = append(args, "--vo=gpu", "--gpu-context=drm")
523 +
	} else {
524 +
		args = append(args, "--vo=gpu")
525 +
	}
526 +
	args = append(args, splitArgs(p.cfg.MpvExtraArgs)...)
527 +
	return args
528 +
}
529 +
530 +
func yesNo(b bool) string {
531 +
	if b {
532 +
		return "yes"
533 +
	}
534 +
	return "no"
535 +
}
536 +
537 +
// splitArgs zerlegt eine Optionszeile und respektiert dabei Anfuehrungszeichen.
538 +
func splitArgs(s string) []string {
539 +
	var out []string
540 +
	var cur strings.Builder
541 +
	var quote rune
542 +
	for _, r := range s {
543 +
		switch {
544 +
		case quote != 0:
545 +
			if r == quote {
546 +
				quote = 0
547 +
			} else {
548 +
				cur.WriteRune(r)
549 +
			}
550 +
		case r == '"' || r == '\'':
551 +
			quote = r
552 +
		case r == ' ' || r == '\t':
553 +
			if cur.Len() > 0 {
554 +
				out = append(out, cur.String())
555 +
				cur.Reset()
556 +
			}
557 +
		default:
558 +
			cur.WriteRune(r)
559 +
		}
560 +
	}
561 +
	if cur.Len() > 0 {
562 +
		out = append(out, cur.String())
563 +
	}
564 +
	return out
565 +
}
566 +
567 +
// ---------------------------------------------------------------------------
568 +
// Steuerung aus dem Webinterface
569 +
// ---------------------------------------------------------------------------
570 +
571 +
func (p *Player) running() bool {
572 +
	p.mu.RLock()
573 +
	defer p.mu.RUnlock()
574 +
	return p.client != nil
575 +
}
576 +
577 +
func (p *Player) send(ch chan int, delta int) error {
578 +
	if !p.running() {
579 +
		return fmt.Errorf("player laeuft gerade nicht")
580 +
	}
581 +
	select {
582 +
	case ch <- delta:
583 +
		return nil
584 +
	default:
585 +
		return fmt.Errorf("player ist beschaeftigt")
586 +
	}
587 +
}
588 +
589 +
// Next springt zum naechsten Eintrag.
590 +
func (p *Player) Next() error { return p.send(p.advance, 1) }
591 +
592 +
// Prev springt zum vorherigen Eintrag.
593 +
func (p *Player) Prev() error { return p.send(p.advance, -1) }
594 +
595 +
// TogglePause haelt die Wiedergabe an oder setzt sie fort.
596 +
func (p *Player) TogglePause() error {
597 +
	p.mu.RLock()
598 +
	c := p.client
599 +
	p.mu.RUnlock()
600 +
	if c == nil {
601 +
		return fmt.Errorf("player laeuft gerade nicht")
602 +
	}
603 +
	_, err := c.Command("cycle", "pause")
604 +
	return err
605 +
}
606 +
607 +
// ReloadNow beginnt die Playliste sofort von vorn.
608 +
func (p *Player) ReloadNow() error {
609 +
	if !p.running() {
610 +
		return fmt.Errorf("player laeuft gerade nicht")
611 +
	}
612 +
	select {
613 +
	case p.reloadNow <- struct{}{}:
614 +
	default:
615 +
	}
616 +
	return nil
617 +
}
618 +
619 +
// Restart startet den mpv-Prozess komplett neu.
620 +
func (p *Player) Restart() error {
621 +
	select {
622 +
	case p.restart <- struct{}{}:
623 +
	default:
624 +
	}
625 +
	return nil
626 +
}
627 +
628 +
func (p *Player) setRunning(v bool) {
629 +
	p.mu.Lock()
630 +
	p.status.Running = v
631 +
	if !v {
632 +
		// p.playing bleibt stehen: Daran erkennt der naechste mpv-Prozess,
633 +
		// wo er wieder einsteigen soll.
634 +
		p.status.Current = ""
635 +
	}
636 +
	p.mu.Unlock()
637 +
}
638 +
639 +
func (p *Player) setError(msg string) {
640 +
	p.mu.Lock()
641 +
	p.status.LastError = msg
642 +
	p.mu.Unlock()
643 +
}
hinzugefügt internal/player/segment.go
+90 −0 Datei ansehen
@@ -0,0 +1,90 @@
1 +
package player
2 +
3 +
import (
4 +
	"path/filepath"
5 +
	"strings"
6 +
7 +
	"ehc/tvwall/internal/config"
8 +
	"ehc/tvwall/internal/playlist"
9 +
)
10 +
11 +
// segment ist ein Stueck Playliste, das ein mpv-Prozess am Stueck abspielt:
12 +
// entweder eine Folge aufeinanderfolgender Bilder oder genau ein Video.
13 +
//
14 +
// Diese Aufteilung loest zwei beobachtete Fehler auf einmal:
15 +
//
16 +
//   - Ein Video, das in einen mpv-Prozess geladen wird, der vorher schon etwas
17 +
//     angezeigt hat, bricht mit "partial file" ab. Jedes Video bekommt deshalb
18 +
//     einen eigenen, frischen Prozess.
19 +
//   - Wird dagegen jedes einzelne Bild per loadfile nachgeladen, bleibt die
20 +
//     Bildausgabe nach etwa neun Wechseln stehen (mpv meldet weiter Wechsel,
21 +
//     der Bildschirm friert ein). Bilderfolgen laufen deshalb als Playliste
22 +
//     innerhalb eines Prozesses - mpv schaltet selbst weiter.
23 +
type segment struct {
24 +
	kind  string // "images" oder "video"
25 +
	names []string
26 +
}
27 +
28 +
// buildSegments fasst aufeinanderfolgende Bilder zusammen; jedes Video wird
29 +
// ein eigener Abschnitt.
30 +
func buildSegments(entries []playlist.Entry) []segment {
31 +
	var segs []segment
32 +
	for _, e := range entries {
33 +
		if e.Kind == "video" {
34 +
			segs = append(segs, segment{kind: "video", names: []string{e.Name}})
35 +
			continue
36 +
		}
37 +
		if n := len(segs); n > 0 && segs[n-1].kind == "images" {
38 +
			segs[n-1].names = append(segs[n-1].names, e.Name)
39 +
			continue
40 +
		}
41 +
		segs = append(segs, segment{kind: "images", names: []string{e.Name}})
42 +
	}
43 +
	return segs
44 +
}
45 +
46 +
func (s segment) equal(o segment) bool {
47 +
	if s.kind != o.kind || len(s.names) != len(o.names) {
48 +
		return false
49 +
	}
50 +
	for i := range s.names {
51 +
		if s.names[i] != o.names[i] {
52 +
			return false
53 +
		}
54 +
	}
55 +
	return true
56 +
}
57 +
58 +
func (s segment) label() string {
59 +
	if s.kind == "video" && len(s.names) == 1 {
60 +
		return "Video " + s.names[0]
61 +
	}
62 +
	return strings.Join(s.names, ", ")
63 +
}
64 +
65 +
// writePlaylist schreibt die Dateien des Abschnitts als m3u fuer mpv.
66 +
func (s segment) writePlaylist(cfg *config.Config, mediaDir string) (string, error) {
67 +
	var b strings.Builder
68 +
	b.WriteString("#EXTM3U\n")
69 +
	for _, n := range s.names {
70 +
		b.WriteString(filepath.Join(mediaDir, n))
71 +
		b.WriteByte('\n')
72 +
	}
73 +
	path := filepath.Join(cfg.StateDir, "segment.m3u")
74 +
	if err := config.WriteFileAtomic(path, []byte(b.String()), 0o664); err != nil {
75 +
		return "", err
76 +
	}
77 +
	return path, nil
78 +
}
79 +
80 +
func sameSegments(a, b []segment) bool {
81 +
	if len(a) != len(b) {
82 +
		return false
83 +
	}
84 +
	for i := range a {
85 +
		if !a[i].equal(b[i]) {
86 +
			return false
87 +
		}
88 +
	}
89 +
	return true
90 +
}
geändert internal/playlist/playlist.go
+33 −19 Datei ansehen
@@ -27,6 +27,19 @@ var (
27 27
	}
28 28
)
29 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 +
30 43
// Kind liefert "image", "video" oder "" fuer nicht unterstuetzte Dateien.
31 44
func Kind(name string) string {
32 45
	ext := strings.ToLower(filepath.Ext(name))
@@ -89,6 +102,14 @@ func (m *Manager) Entries() []Entry {
89 102
	return append([]Entry(nil), m.entries...)
90 103
}
91 104
105 +
// EntriesWithVersion liefert Playliste und Version in einem Rutsch, damit der
106 +
// Player beides konsistent zueinander sieht.
107 +
func (m *Manager) EntriesWithVersion() ([]Entry, uint64) {
108 +
	m.mu.RLock()
109 +
	defer m.mu.RUnlock()
110 +
	return append([]Entry(nil), m.entries...), m.version
111 +
}
112 +
92 113
// Version steigt bei jeder Aenderung - praktisch fuer das Polling im Webinterface.
93 114
func (m *Manager) Version() uint64 {
94 115
	m.mu.RLock()
@@ -148,7 +169,7 @@ func (m *Manager) Scan() (bool, error) {
148 169
		}
149 170
	}
150 171
151 -
	return m.commit(ordered, len(ordered) != len(stored))
172 +
	return m.commit(ordered)
152 173
}
153 174
154 175
// SetOrder setzt die Reihenfolge neu. Nicht genannte (aber vorhandene) Dateien
@@ -177,7 +198,7 @@ func (m *Manager) SetOrder(names []string) error {
177 198
		}
178 199
	}
179 200
180 -
	_, err := m.commit(ordered, true)
201 +
	_, err := m.commit(ordered)
181 202
	return err
182 203
}
183 204
@@ -195,26 +216,17 @@ func (m *Manager) Delete(name string) error {
195 216
}
196 217
197 218
// 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) {
219 +
// und meldet die Aenderung an den Player.
220 +
func (m *Manager) commit(ordered []Entry) (bool, error) {
200 221
	m.mu.Lock()
201 -
	changed := force || !sameEntries(m.entries, ordered)
202 -
	if !changed {
222 +
	if sameEntries(m.entries, ordered) {
203 223
		m.mu.Unlock()
204 224
		return false, nil
205 225
	}
206 -
	// Auch bei force pruefen, ob sich inhaltlich wirklich etwas geaendert hat.
207 -
	realChange := !sameEntries(m.entries, ordered)
208 226
	m.entries = ordered
209 -
	if realChange {
210 -
		m.version++
211 -
	}
227 +
	m.version++
212 228
	m.mu.Unlock()
213 229
214 -
	if !realChange {
215 -
		return false, nil
216 -
	}
217 -
218 230
	names := make([]string, len(ordered))
219 231
	var m3u strings.Builder
220 232
	m3u.WriteString("#EXTM3U\n")
@@ -224,10 +236,12 @@ func (m *Manager) commit(ordered []Entry, force bool) (bool, error) {
224 236
		m3u.WriteByte('\n')
225 237
	}
226 238
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 -
		}
239 +
	data, err := json.MarshalIndent(names, "", "  ")
240 +
	if err != nil {
241 +
		return true, err
242 +
	}
243 +
	if err := config.WriteFileAtomic(m.orderFile, append(data, '\n'), 0o664); err != nil {
244 +
		return true, fmt.Errorf("order.json schreiben: %w", err)
231 245
	}
232 246
	if err := config.WriteFileAtomic(m.playlistFile, []byte(m3u.String()), 0o664); err != nil {
233 247
		return true, fmt.Errorf("playlist.m3u schreiben: %w", err)
hinzugefügt internal/web/assets.go
+35 −0 Datei ansehen
@@ -0,0 +1,35 @@
1 +
package web
2 +
3 +
import (
4 +
	"embed"
5 +
	"io/fs"
6 +
	"log"
7 +
	"net/http"
8 +
	"os/exec"
9 +
)
10 +
11 +
//go:embed assets
12 +
var assetsFS embed.FS
13 +
14 +
// staticHandler liefert die eingebetteten CSS-/JS-Dateien aus.
15 +
func staticHandler() http.Handler {
16 +
	sub, err := fs.Sub(assetsFS, "assets")
17 +
	if err != nil {
18 +
		log.Fatalf("web: eingebettete dateien: %v", err)
19 +
	}
20 +
	return http.FileServer(http.FS(sub))
21 +
}
22 +
23 +
// writeAsset schreibt eine eingebettete Datei direkt in die Antwort.
24 +
func writeAsset(w http.ResponseWriter, name string) {
25 +
	data, err := assetsFS.ReadFile("assets/" + name)
26 +
	if err != nil {
27 +
		http.Error(w, "Datei fehlt: "+name, http.StatusInternalServerError)
28 +
		return
29 +
	}
30 +
	if _, err := w.Write(data); err != nil {
31 +
		log.Printf("web: %s ausliefern: %v", name, err)
32 +
	}
33 +
}
34 +
35 +
func lookPath(name string) (string, error) { return exec.LookPath(name) }
hinzugefügt internal/web/assets/app.js
+362 −0 Datei ansehen
@@ -0,0 +1,362 @@
1 +
/* TV-Wall Webinterface. Reines Vanilla-JS ohne externe Bibliotheken, damit die
2 +
   Oberflaeche auch ohne Internetverbindung (Pi als Access Point) funktioniert. */
3 +
'use strict';
4 +
5 +
const $ = (id) => document.getElementById(id);
6 +
7 +
const el = {
8 +
  deviceLine: $('deviceLine'),
9 +
  nowText: $('nowText'),
10 +
  statusDot: $('statusDot'),
11 +
  pauseBtn: $('pauseBtn'),
12 +
  list: $('list'),
13 +
  empty: $('empty'),
14 +
  counter: $('counter'),
15 +
  drop: $('drop'),
16 +
  fileInput: $('fileInput'),
17 +
  dropHint: $('dropHint'),
18 +
  progressWrap: $('progressWrap'),
19 +
  progressFill: $('progressFill'),
20 +
  progressLabel: $('progressLabel'),
21 +
  durationInput: $('durationInput'),
22 +
  muteInput: $('muteInput'),
23 +
  saveSettings: $('saveSettings'),
24 +
  diskLine: $('diskLine'),
25 +
  toasts: $('toasts'),
26 +
};
27 +
28 +
let state = null;
29 +
let renderedVersion = -1;
30 +
let busy = false;          // laeuft gerade ein Upload?
31 +
let dragging = false;      // wird gerade sortiert?
32 +
let settingsTouched = false;
33 +
34 +
/* ------------------------------------------------------------------ Helfer */
35 +
36 +
function toast(msg, kind) {
37 +
  const node = document.createElement('div');
38 +
  node.className = 'toast' + (kind ? ' ' + kind : '');
39 +
  node.textContent = msg;
40 +
  el.toasts.appendChild(node);
41 +
  setTimeout(() => node.remove(), kind === 'err' ? 6000 : 3200);
42 +
}
43 +
44 +
function formatSize(bytes) {
45 +
  if (!bytes) return '0 B';
46 +
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
47 +
  let i = 0, n = bytes;
48 +
  while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
49 +
  return (n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)) + ' ' + units[i];
50 +
}
51 +
52 +
async function api(path, body) {
53 +
  const opts = body === undefined
54 +
    ? {}
55 +
    : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
56 +
  const res = await fetch(path, opts);
57 +
  let data = null;
58 +
  try { data = await res.json(); } catch (e) { /* leere Antwort ist ok */ }
59 +
  if (!res.ok) throw new Error((data && data.error) || ('HTTP ' + res.status));
60 +
  return data;
61 +
}
62 +
63 +
/* -------------------------------------------------------------- Darstellung */
64 +
65 +
async function refresh() {
66 +
  if (busy || dragging) return;
67 +
  try {
68 +
    state = await api('/api/state');
69 +
  } catch (e) {
70 +
    el.statusDot.className = 'dot err';
71 +
    el.nowText.textContent = 'keine Verbindung';
72 +
    return;
73 +
  }
74 +
  renderStatus();
75 +
  if (state.version !== renderedVersion) {
76 +
    renderList();
77 +
    renderedVersion = state.version;
78 +
  } else {
79 +
    markPlaying();
80 +
  }
81 +
}
82 +
83 +
function renderStatus() {
84 +
  const d = state.device, s = state.status;
85 +
  el.deviceLine.textContent = d.ssid + ' · ' + d.hostname + '.local';
86 +
87 +
  if (!s.running) {
88 +
    el.statusDot.className = 'dot err';
89 +
    el.nowText.textContent = s.last_error ? 'Player-Fehler' : 'Player startet …';
90 +
  } else if (s.current) {
91 +
    el.statusDot.className = 'dot on';
92 +
    el.nowText.textContent = (s.paused ? 'Pause: ' : 'Läuft: ') + s.current;
93 +
  } else {
94 +
    el.statusDot.className = 'dot';
95 +
    el.nowText.textContent = state.entries.length ? 'wird geladen …' : 'Playliste leer';
96 +
  }
97 +
  el.pauseBtn.textContent = s.paused ? '▶ Weiter' : '⏸ Pause';
98 +
99 +
  el.counter.textContent = state.entries.length
100 +
    ? `${state.entries.length} Einträge · ${d.images} Bilder · ${d.videos} Videos`
101 +
    : '';
102 +
103 +
  if (!settingsTouched) {
104 +
    el.durationInput.value = state.settings.image_duration;
105 +
    el.muteInput.checked = state.settings.mute;
106 +
  }
107 +
108 +
  el.dropHint.textContent = 'Bilder und Videos · max. ' + d.max_upload_mb + ' MB pro Vorgang';
109 +
  const parts = [];
110 +
  if (state.disk.total) parts.push(formatSize(state.disk.free) + ' von ' + formatSize(state.disk.total) + ' frei');
111 +
  if (s.display) parts.push('Bildausgabe: ' + s.display);
112 +
  el.diskLine.textContent = parts.join(' · ');
113 +
}
114 +
115 +
function renderList() {
116 +
  el.list.textContent = '';
117 +
  el.empty.hidden = state.entries.length > 0;
118 +
119 +
  for (const entry of state.entries) {
120 +
    const li = document.createElement('li');
121 +
    li.className = 'item';
122 +
    li.dataset.name = entry.name;
123 +
124 +
    const grip = document.createElement('div');
125 +
    grip.className = 'grip';
126 +
    grip.textContent = '⠿';
127 +
    grip.title = 'Zum Sortieren ziehen';
128 +
    grip.addEventListener('pointerdown', (ev) => startDrag(ev, li));
129 +
130 +
    const img = document.createElement('img');
131 +
    img.className = 'thumb';
132 +
    img.loading = 'lazy';
133 +
    img.alt = '';
134 +
    img.src = '/thumb/' + encodeURIComponent(entry.name);
135 +
136 +
    const meta = document.createElement('div');
137 +
    meta.className = 'meta';
138 +
    const name = document.createElement('div');
139 +
    name.className = 'name';
140 +
    name.textContent = entry.name;
141 +
    name.title = entry.name;
142 +
    const tags = document.createElement('div');
143 +
    tags.className = 'tags';
144 +
    const badge = document.createElement('span');
145 +
    badge.className = 'badge' + (entry.kind === 'video' ? ' video' : '');
146 +
    badge.textContent = entry.kind === 'video' ? 'Video' : 'Bild';
147 +
    const size = document.createElement('span');
148 +
    size.textContent = formatSize(entry.size);
149 +
    tags.append(badge, size);
150 +
    meta.append(name, tags);
151 +
152 +
    const actions = document.createElement('div');
153 +
    actions.className = 'actions';
154 +
    actions.append(
155 +
      iconButton('↑', 'Nach oben', () => move(entry.name, -1)),
156 +
      iconButton('↓', 'Nach unten', () => move(entry.name, 1)),
157 +
      iconButton('🗑', 'Löschen', () => remove(entry.name), 'del'),
158 +
    );
159 +
160 +
    li.append(grip, img, meta, actions);
161 +
    el.list.appendChild(li);
162 +
  }
163 +
  markPlaying();
164 +
}
165 +
166 +
function iconButton(label, title, onClick, extra) {
167 +
  const b = document.createElement('button');
168 +
  b.className = 'icon-btn' + (extra ? ' ' + extra : '');
169 +
  b.type = 'button';
170 +
  b.textContent = label;
171 +
  b.title = title;
172 +
  b.setAttribute('aria-label', title);
173 +
  b.addEventListener('click', onClick);
174 +
  return b;
175 +
}
176 +
177 +
function markPlaying() {
178 +
  const current = state && state.status ? state.status.current : '';
179 +
  for (const li of el.list.children) {
180 +
    li.classList.toggle('playing', !!current && li.dataset.name === current);
181 +
  }
182 +
}
183 +
184 +
/* ------------------------------------------------------------- Sortieren */
185 +
186 +
function currentOrder() {
187 +
  return Array.from(el.list.children).map((li) => li.dataset.name);
188 +
}
189 +
190 +
async function saveOrder() {
191 +
  const order = currentOrder();
192 +
  try {
193 +
    await api('/api/order', { order });
194 +
    renderedVersion = -1; // beim naechsten Abruf frisch zeichnen
195 +
    toast('Reihenfolge gespeichert', 'ok');
196 +
  } catch (e) {
197 +
    toast('Reihenfolge speichern fehlgeschlagen: ' + e.message, 'err');
198 +
  }
199 +
}
200 +
201 +
function move(name, delta) {
202 +
  const items = Array.from(el.list.children);
203 +
  const idx = items.findIndex((li) => li.dataset.name === name);
204 +
  const target = idx + delta;
205 +
  if (idx < 0 || target < 0 || target >= items.length) return;
206 +
  if (delta < 0) el.list.insertBefore(items[idx], items[target]);
207 +
  else el.list.insertBefore(items[target], items[idx]);
208 +
  saveOrder();
209 +
}
210 +
211 +
// Sortieren per Zeigergeraet - funktioniert mit Maus und Finger gleichermassen.
212 +
let dragItem = null;
213 +
214 +
function startDrag(ev, li) {
215 +
  if (ev.button !== undefined && ev.button !== 0) return;
216 +
  ev.preventDefault();
217 +
  dragging = true;
218 +
  dragItem = li;
219 +
  li.classList.add('dragging');
220 +
  ev.target.setPointerCapture(ev.pointerId);
221 +
  ev.target.addEventListener('pointermove', onDragMove);
222 +
  ev.target.addEventListener('pointerup', endDrag);
223 +
  ev.target.addEventListener('pointercancel', endDrag);
224 +
}
225 +
226 +
function onDragMove(ev) {
227 +
  if (!dragItem) return;
228 +
  ev.preventDefault();
229 +
  const over = document.elementFromPoint(ev.clientX, ev.clientY);
230 +
  const target = over && over.closest ? over.closest('.item') : null;
231 +
  if (!target || target === dragItem || target.parentElement !== el.list) return;
232 +
  const rect = target.getBoundingClientRect();
233 +
  const after = ev.clientY > rect.top + rect.height / 2;
234 +
  el.list.insertBefore(dragItem, after ? target.nextSibling : target);
235 +
}
236 +
237 +
function endDrag(ev) {
238 +
  if (!dragItem) return;
239 +
  ev.target.removeEventListener('pointermove', onDragMove);
240 +
  ev.target.removeEventListener('pointerup', endDrag);
241 +
  ev.target.removeEventListener('pointercancel', endDrag);
242 +
  dragItem.classList.remove('dragging');
243 +
  dragItem = null;
244 +
  dragging = false;
245 +
  saveOrder();
246 +
}
247 +
248 +
async function remove(name) {
249 +
  if (!confirm('"' + name + '" wirklich löschen?')) return;
250 +
  try {
251 +
    await api('/api/delete', { name });
252 +
    renderedVersion = -1;
253 +
    await refresh();
254 +
    toast('Gelöscht: ' + name, 'ok');
255 +
  } catch (e) {
256 +
    toast('Löschen fehlgeschlagen: ' + e.message, 'err');
257 +
  }
258 +
}
259 +
260 +
/* --------------------------------------------------------------- Hochladen */
261 +
262 +
function upload(files) {
263 +
  const list = Array.from(files || []);
264 +
  if (!list.length) {
265 +
    toast('Es wurde keine Datei ausgewählt', 'err');
266 +
    return;
267 +
  }
268 +
269 +
  const form = new FormData();
270 +
  for (const f of list) form.append('files', f, f.name);
271 +
272 +
  busy = true;
273 +
  el.progressWrap.hidden = false;
274 +
  el.progressFill.style.width = '0%';
275 +
  el.progressLabel.textContent = '0 %';
276 +
277 +
  const xhr = new XMLHttpRequest();
278 +
  xhr.open('POST', '/api/upload');
279 +
  xhr.upload.addEventListener('progress', (ev) => {
280 +
    if (!ev.lengthComputable) return;
281 +
    const pct = Math.round((ev.loaded / ev.total) * 100);
282 +
    el.progressFill.style.width = pct + '%';
283 +
    el.progressLabel.textContent = pct + ' %';
284 +
  });
285 +
  xhr.addEventListener('load', () => {
286 +
    busy = false;
287 +
    el.progressWrap.hidden = true;
288 +
    // Erst jetzt zuruecksetzen: iOS gibt die ausgewaehlten Dateien frei,
289 +
    // sobald das Feld geleert wird - vorher waere der Upload leer.
290 +
    el.fileInput.value = '';
291 +
    let data = null;
292 +
    try { data = JSON.parse(xhr.responseText); } catch (e) { /* egal */ }
293 +
    if (xhr.status >= 200 && xhr.status < 300 && data) {
294 +
      if (data.uploaded.length) toast(data.uploaded.length + ' Datei(en) hinzugefügt', 'ok');
295 +
      for (const s of data.skipped || []) toast('Übersprungen: ' + s, 'err');
296 +
    } else {
297 +
      toast('Upload fehlgeschlagen: ' + ((data && data.error) || ('HTTP ' + xhr.status)), 'err');
298 +
    }
299 +
    renderedVersion = -1;
300 +
    refresh();
301 +
  });
302 +
  xhr.addEventListener('error', () => {
303 +
    busy = false;
304 +
    el.progressWrap.hidden = true;
305 +
    el.fileInput.value = '';
306 +
    toast('Upload fehlgeschlagen (Verbindung)', 'err');
307 +
  });
308 +
  xhr.send(form);
309 +
}
310 +
311 +
/* ----------------------------------------------------------- Verdrahtung */
312 +
313 +
// Kein click()-Aufruf auf das Feld: das Label oeffnet die Auswahl bereits
314 +
// selbst. Beides zusammen laesst die Fotoauswahl auf dem iPhone ohne Ergebnis
315 +
// wieder zugehen.
316 +
el.fileInput.addEventListener('change', () => upload(el.fileInput.files));
317 +
318 +
for (const type of ['dragenter', 'dragover']) {
319 +
  el.drop.addEventListener(type, (ev) => { ev.preventDefault(); el.drop.classList.add('over'); });
320 +
}
321 +
for (const type of ['dragleave', 'drop']) {
322 +
  el.drop.addEventListener(type, (ev) => { ev.preventDefault(); el.drop.classList.remove('over'); });
323 +
}
324 +
el.drop.addEventListener('drop', (ev) => {
325 +
  if (ev.dataTransfer && ev.dataTransfer.files) upload(ev.dataTransfer.files);
326 +
});
327 +
// Verhindert, dass der Browser eine daneben abgelegte Datei einfach oeffnet.
328 +
window.addEventListener('dragover', (ev) => ev.preventDefault());
329 +
window.addEventListener('drop', (ev) => ev.preventDefault());
330 +
331 +
document.querySelectorAll('[data-action]').forEach((btn) => {
332 +
  btn.addEventListener('click', async () => {
333 +
    try {
334 +
      await api('/api/control', { action: btn.dataset.action });
335 +
      setTimeout(refresh, 250);
336 +
    } catch (e) {
337 +
      toast('Aktion fehlgeschlagen: ' + e.message, 'err');
338 +
    }
339 +
  });
340 +
});
341 +
342 +
el.durationInput.addEventListener('input', () => { settingsTouched = true; });
343 +
el.muteInput.addEventListener('change', () => { settingsTouched = true; });
344 +
345 +
el.saveSettings.addEventListener('click', async () => {
346 +
  const value = parseFloat(el.durationInput.value);
347 +
  if (!(value >= 0.5 && value <= 3600)) {
348 +
    toast('Anzeigedauer muss zwischen 0,5 und 3600 Sekunden liegen', 'err');
349 +
    return;
350 +
  }
351 +
  try {
352 +
    await api('/api/settings', { image_duration: value, mute: el.muteInput.checked });
353 +
    settingsTouched = false;
354 +
    toast('Einstellungen gespeichert', 'ok');
355 +
    refresh();
356 +
  } catch (e) {
357 +
    toast('Speichern fehlgeschlagen: ' + e.message, 'err');
358 +
  }
359 +
});
360 +
361 +
refresh();
362 +
setInterval(refresh, 3000);
hinzugefügt internal/web/assets/index.html
+90 −0 Datei ansehen
@@ -0,0 +1,90 @@
1 +
<!doctype html>
2 +
<html lang="de">
3 +
<head>
4 +
<meta charset="utf-8">
5 +
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 +
<meta name="theme-color" content="#11161f">
7 +
<title>TV-Wall</title>
8 +
<link rel="stylesheet" href="/static/style.css">
9 +
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%23111820'/><rect x='2.5' y='4' width='11' height='7' rx='1' fill='%2352b6ff'/><rect x='6' y='12' width='4' height='1.2' rx='.6' fill='%2352b6ff'/></svg>">
10 +
</head>
11 +
<body>
12 +
13 +
<header class="top">
14 +
  <div class="brand">
15 +
    <span class="logo" aria-hidden="true"></span>
16 +
    <div>
17 +
      <h1>TV-Wall</h1>
18 +
      <p class="sub" id="deviceLine">wird geladen …</p>
19 +
    </div>
20 +
  </div>
21 +
  <div class="now" id="nowPlaying">
22 +
    <span class="dot" id="statusDot"></span>
23 +
    <span id="nowText">…</span>
24 +
  </div>
25 +
</header>
26 +
27 +
<main>
28 +
29 +
  <section class="card" id="uploadCard">
30 +
    <h2>Hochladen</h2>
31 +
    <!-- Das Feld steht bewusst AUSSERHALB des Labels und ist nicht per
32 +
         hidden-Attribut versteckt: iOS Safari oeffnet die Fotoauswahl sonst
33 +
         zweimal (einmal ueber das Label, einmal per JavaScript) und verwirft
34 +
         das Ergebnis. -->
35 +
    <input type="file" id="fileInput" class="visually-hidden" multiple accept="image/*,video/*">
36 +
    <label class="drop" id="drop" for="fileInput">
37 +
      <span class="drop-icon" aria-hidden="true">+</span>
38 +
      <span class="drop-text">Dateien hierher ziehen<br><b>oder tippen zum Auswählen</b></span>
39 +
      <span class="drop-hint" id="dropHint">Bilder und Videos</span>
40 +
    </label>
41 +
    <div class="progress" id="progressWrap" hidden>
42 +
      <div class="bar"><div class="fill" id="progressFill"></div></div>
43 +
      <span class="progress-label" id="progressLabel">0 %</span>
44 +
    </div>
45 +
  </section>
46 +
47 +
  <section class="card">
48 +
    <h2>Wiedergabe</h2>
49 +
    <div class="controls">
50 +
      <button class="btn" data-action="prev" title="Vorheriger Eintrag">⏮ Zurück</button>
51 +
      <button class="btn" data-action="pause" id="pauseBtn">⏸ Pause</button>
52 +
      <button class="btn" data-action="next" title="Nächster Eintrag">⏭ Weiter</button>
53 +
      <button class="btn ghost" data-action="reload" title="Playliste sofort neu laden">↻ Neu laden</button>
54 +
    </div>
55 +
    <div class="settings">
56 +
      <label class="field">
57 +
        <span>Anzeigedauer Bilder</span>
58 +
        <span class="input-unit">
59 +
          <input type="number" id="durationInput" min="0.5" max="3600" step="0.5" inputmode="decimal">
60 +
          <span>Sek.</span>
61 +
        </span>
62 +
      </label>
63 +
      <label class="switch">
64 +
        <input type="checkbox" id="muteInput">
65 +
        <span>Ton der Videos stummschalten</span>
66 +
      </label>
67 +
      <button class="btn small" id="saveSettings">Speichern</button>
68 +
    </div>
69 +
  </section>
70 +
71 +
  <section class="card">
72 +
    <div class="card-head">
73 +
      <h2>Playliste</h2>
74 +
      <span class="counter" id="counter"></span>
75 +
    </div>
76 +
    <p class="hint">Reihenfolge per Ziehen ändern – oder mit den Pfeiltasten. Änderungen wirken sofort.</p>
77 +
    <ul class="list" id="list"></ul>
78 +
    <p class="empty" id="empty" hidden>Noch nichts da. Lade oben Bilder oder Videos hoch.</p>
79 +
  </section>
80 +
81 +
  <footer class="foot">
82 +
    <span id="diskLine"></span>
83 +
  </footer>
84 +
</main>
85 +
86 +
<div class="toasts" id="toasts" aria-live="polite"></div>
87 +
88 +
<script src="/static/app.js"></script>
89 +
</body>
90 +
</html>
hinzugefügt internal/web/assets/style.css
+184 −0 Datei ansehen
@@ -0,0 +1,184 @@
1 +
/* TV-Wall Webinterface - bewusst ohne externe Abhaengigkeiten, da der Pi als
2 +
   Access Point keine Internetverbindung hat. */
3 +
4 +
:root {
5 +
  --bg: #0d1117;
6 +
  --bg-card: #161c26;
7 +
  --bg-soft: #1e2632;
8 +
  --line: #2a3442;
9 +
  --fg: #e7edf5;
10 +
  --fg-dim: #94a3b8;
11 +
  --accent: #52b6ff;
12 +
  --accent-dark: #1d84cc;
13 +
  --danger: #ff6b6b;
14 +
  --ok: #4ade80;
15 +
  --radius: 14px;
16 +
}
17 +
18 +
* { box-sizing: border-box; }
19 +
20 +
html { -webkit-text-size-adjust: 100%; }
21 +
22 +
body {
23 +
  margin: 0;
24 +
  padding: 0 0 3rem;
25 +
  background: var(--bg);
26 +
  color: var(--fg);
27 +
  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
28 +
}
29 +
30 +
h1, h2 { margin: 0; font-weight: 650; }
31 +
h1 { font-size: 1.15rem; letter-spacing: .02em; }
32 +
h2 { font-size: .95rem; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-dim); }
33 +
34 +
/* ---------- Kopfzeile ---------- */
35 +
.top {
36 +
  position: sticky; top: 0; z-index: 20;
37 +
  display: flex; flex-wrap: wrap; gap: .75rem;
38 +
  align-items: center; justify-content: space-between;
39 +
  padding: .85rem 1rem;
40 +
  padding-top: max(.85rem, env(safe-area-inset-top));
41 +
  background: rgba(13, 17, 23, .92);
42 +
  backdrop-filter: blur(8px);
43 +
  border-bottom: 1px solid var(--line);
44 +
}
45 +
.brand { display: flex; align-items: center; gap: .7rem; }
46 +
.logo {
47 +
  width: 34px; height: 34px; border-radius: 9px; flex: none;
48 +
  background: linear-gradient(150deg, var(--accent), var(--accent-dark));
49 +
  box-shadow: 0 0 0 1px rgba(255,255,255,.08) inset;
50 +
}
51 +
.sub { margin: 0; font-size: .78rem; color: var(--fg-dim); }
52 +
.now {
53 +
  display: flex; align-items: center; gap: .5rem;
54 +
  font-size: .82rem; color: var(--fg-dim);
55 +
  max-width: 100%; overflow: hidden;
56 +
}
57 +
.now span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
58 +
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--fg-dim); flex: none; }
59 +
.dot.on { background: var(--ok); box-shadow: 0 0 8px rgba(74,222,128,.7); }
60 +
.dot.err { background: var(--danger); }
61 +
62 +
/* ---------- Layout ---------- */
63 +
main { max-width: 780px; margin: 0 auto; padding: 1rem; display: grid; gap: 1rem; }
64 +
.card { background: var(--bg-card); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
65 +
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
66 +
.counter { font-size: .8rem; color: var(--fg-dim); }
67 +
.hint { margin: .5rem 0 .9rem; font-size: .8rem; color: var(--fg-dim); }
68 +
69 +
/* ---------- Upload ---------- */
70 +
/* Nicht display:none - sonst laesst sich das Feld auf manchen Browsern nicht
71 +
   ueber das Label ansprechen. */
72 +
.visually-hidden {
73 +
  position: absolute; width: 1px; height: 1px;
74 +
  padding: 0; margin: -1px; overflow: hidden;
75 +
  clip-path: inset(50%); white-space: nowrap; border: 0;
76 +
}
77 +
78 +
.drop {
79 +
  margin-top: .8rem;
80 +
  display: flex; flex-direction: column; align-items: center; justify-content: center;
81 +
  gap: .4rem; text-align: center;
82 +
  min-height: 140px; padding: 1.2rem;
83 +
  border: 2px dashed var(--line); border-radius: var(--radius);
84 +
  background: var(--bg-soft); cursor: pointer;
85 +
  transition: border-color .15s, background .15s;
86 +
}
87 +
.drop:active, .drop.over { border-color: var(--accent); background: #1b2939; }
88 +
.drop-icon { font-size: 1.8rem; color: var(--accent); line-height: 1; }
89 +
.drop-text { font-size: .92rem; color: var(--fg-dim); }
90 +
.drop-text b { color: var(--fg); font-weight: 600; }
91 +
.drop-hint { font-size: .74rem; color: var(--fg-dim); opacity: .75; }
92 +
93 +
.progress { display: flex; align-items: center; gap: .7rem; margin-top: .8rem; }
94 +
.bar { flex: 1; height: 8px; border-radius: 99px; background: var(--bg-soft); overflow: hidden; }
95 +
.fill { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent-dark), var(--accent)); transition: width .2s; }
96 +
.progress-label { font-size: .78rem; color: var(--fg-dim); min-width: 3.2rem; text-align: right; }
97 +
98 +
/* ---------- Buttons ---------- */
99 +
.controls { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .8rem; }
100 +
.btn {
101 +
  flex: 1 1 auto; min-width: 6.5rem; min-height: 44px;
102 +
  padding: .55rem .9rem;
103 +
  border: 1px solid var(--line); border-radius: 10px;
104 +
  background: var(--bg-soft); color: var(--fg);
105 +
  font-size: .9rem; font-weight: 550; cursor: pointer;
106 +
  transition: background .12s, border-color .12s;
107 +
}
108 +
.btn:hover { border-color: var(--accent); }
109 +
.btn:active { background: #253242; }
110 +
.btn.ghost { background: transparent; color: var(--fg-dim); }
111 +
.btn.small { flex: 0 0 auto; min-width: auto; min-height: 38px; font-size: .82rem; }
112 +
.btn[disabled] { opacity: .45; cursor: default; }
113 +
114 +
/* ---------- Einstellungen ---------- */
115 +
.settings { display: flex; flex-wrap: wrap; align-items: center; gap: .9rem; margin-top: 1rem;
116 +
  padding-top: 1rem; border-top: 1px solid var(--line); }
117 +
.field { display: flex; flex-direction: column; gap: .3rem; font-size: .82rem; color: var(--fg-dim); }
118 +
.input-unit { display: flex; align-items: center; gap: .4rem; }
119 +
.input-unit span { font-size: .8rem; }
120 +
input[type="number"] {
121 +
  width: 5.5rem; min-height: 40px; padding: .35rem .6rem;
122 +
  border: 1px solid var(--line); border-radius: 9px;
123 +
  background: var(--bg-soft); color: var(--fg); font-size: .95rem;
124 +
}
125 +
.switch { display: flex; align-items: center; gap: .5rem; font-size: .82rem; color: var(--fg-dim); cursor: pointer; }
126 +
.switch input { width: 20px; height: 20px; accent-color: var(--accent); }
127 +
128 +
/* ---------- Playliste ---------- */
129 +
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
130 +
.item {
131 +
  display: grid;
132 +
  grid-template-columns: auto 84px 1fr auto;
133 +
  align-items: center; gap: .7rem;
134 +
  padding: .5rem; border: 1px solid var(--line); border-radius: 12px;
135 +
  background: var(--bg-soft);
136 +
}
137 +
.item.playing { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(82,182,255,.25); }
138 +
.item.dragging { opacity: .4; }
139 +
.item.drop-target { border-color: var(--accent); border-style: dashed; }
140 +
.grip { cursor: grab; padding: .4rem .2rem; color: var(--fg-dim); font-size: 1.1rem; line-height: 1; touch-action: none; user-select: none; }
141 +
.grip:active { cursor: grabbing; }
142 +
.thumb {
143 +
  width: 84px; height: 56px; border-radius: 8px; object-fit: cover;
144 +
  background: #0b1220; display: block;
145 +
}
146 +
.meta { min-width: 0; }
147 +
.name { font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
148 +
.tags { display: flex; gap: .4rem; align-items: center; margin-top: .2rem; font-size: .72rem; color: var(--fg-dim); }
149 +
.badge { padding: .1rem .4rem; border-radius: 5px; background: #223247; color: #9dc7ee; font-size: .68rem; letter-spacing: .03em; }
150 +
.badge.video { background: #33244a; color: #c6a8f0; }
151 +
.actions { display: flex; gap: .25rem; }
152 +
.icon-btn {
153 +
  width: 38px; height: 38px; display: grid; place-items: center;
154 +
  border: 1px solid transparent; border-radius: 9px;
155 +
  background: transparent; color: var(--fg-dim);
156 +
  font-size: 1rem; cursor: pointer;
157 +
}
158 +
.icon-btn:hover { background: #22303f; color: var(--fg); }
159 +
.icon-btn.del:hover { color: var(--danger); }
160 +
.empty { color: var(--fg-dim); font-size: .85rem; text-align: center; padding: 1.2rem 0; }
161 +
162 +
.foot { text-align: center; font-size: .74rem; color: var(--fg-dim); opacity: .8; }
163 +
164 +
/* ---------- Meldungen ---------- */
165 +
.toasts {
166 +
  position: fixed; left: 50%; transform: translateX(-50%);
167 +
  bottom: max(1rem, env(safe-area-inset-bottom)); z-index: 50;
168 +
  display: grid; gap: .4rem; width: min(90vw, 460px);
169 +
}
170 +
.toast {
171 +
  padding: .6rem .9rem; border-radius: 10px;
172 +
  background: #223142; border: 1px solid var(--line);
173 +
  color: var(--fg); font-size: .85rem;
174 +
  box-shadow: 0 8px 24px rgba(0,0,0,.45);
175 +
  animation: pop .2s ease-out;
176 +
}
177 +
.toast.err { background: #3a2028; border-color: #6b2b36; }
178 +
.toast.ok { background: #1c3527; border-color: #2f6b47; }
179 +
@keyframes pop { from { opacity: 0; transform: translateY(8px); } }
180 +
181 +
@media (max-width: 420px) {
182 +
  .item { grid-template-columns: auto 64px 1fr auto; }
183 +
  .thumb { width: 64px; height: 44px; }
184 +
}
hinzugefügt internal/web/disk_linux.go
+14 −0 Datei ansehen
@@ -0,0 +1,14 @@
1 +
//go:build linux
2 +
3 +
package web
4 +
5 +
import "syscall"
6 +
7 +
// diskUsage liefert freien und gesamten Speicherplatz des Datentraegers.
8 +
func diskUsage(path string) (free, total int64) {
9 +
	var st syscall.Statfs_t
10 +
	if err := syscall.Statfs(path, &st); err != nil {
11 +
		return 0, 0
12 +
	}
13 +
	return int64(st.Bavail) * int64(st.Bsize), int64(st.Blocks) * int64(st.Bsize)
14 +
}
hinzugefügt internal/web/disk_other.go
+7 −0 Datei ansehen
@@ -0,0 +1,7 @@
1 +
//go:build !linux
2 +
3 +
package web
4 +
5 +
// diskUsage ist nur unter Linux implementiert - auf anderen Systemen wird die
6 +
// Anzeige im Webinterface einfach ausgeblendet.
7 +
func diskUsage(string) (free, total int64) { return 0, 0 }
hinzugefügt internal/web/thumb.go
+79 −0 Datei ansehen
@@ -0,0 +1,79 @@
1 +
package web
2 +
3 +
import (
4 +
	"bytes"
5 +
	"context"
6 +
	"fmt"
7 +
	"os"
8 +
	"os/exec"
9 +
	"path/filepath"
10 +
	"time"
11 +
)
12 +
13 +
type diskInfo struct {
14 +
	Free  int64 `json:"free"`
15 +
	Total int64 `json:"total"`
16 +
}
17 +
18 +
// makeThumb erzeugt mit ffmpeg ein Vorschaubild. Bei Videos wird ein Einzelbild
19 +
// aus der ersten Sekunde genommen.
20 +
func makeThumb(src, dst, kind string) error {
21 +
	if err := os.MkdirAll(filepath.Dir(dst), 0o775); err != nil {
22 +
		return err
23 +
	}
24 +
	tmp := dst + ".tmp.jpg"
25 +
	defer os.Remove(tmp)
26 +
27 +
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
28 +
	defer cancel()
29 +
30 +
	seek := "1"
31 +
	if kind != "video" {
32 +
		seek = ""
33 +
	}
34 +
	err := runFFmpeg(ctx, src, tmp, seek)
35 +
	if err != nil && seek != "" {
36 +
		// Sehr kurze Videos haben nach einer Sekunde nichts mehr: von vorne.
37 +
		err = runFFmpeg(ctx, src, tmp, "")
38 +
	}
39 +
	if err != nil {
40 +
		return err
41 +
	}
42 +
	return os.Rename(tmp, dst)
43 +
}
44 +
45 +
// convertToJPEG wandelt ein Standbild (etwa HEIC vom iPhone) in JPEG um.
46 +
func convertToJPEG(src, dst string) error {
47 +
	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
48 +
	defer cancel()
49 +
	out, err := exec.CommandContext(ctx, "ffmpeg",
50 +
		"-nostdin", "-loglevel", "error", "-y",
51 +
		"-i", src,
52 +
		"-frames:v", "1",
53 +
		"-q:v", "2",
54 +
		"-f", "image2", dst,
55 +
	).CombinedOutput()
56 +
	if err != nil {
57 +
		return fmt.Errorf("ffmpeg: %v: %s", err, bytes.TrimSpace(out))
58 +
	}
59 +
	return nil
60 +
}
61 +
62 +
func runFFmpeg(ctx context.Context, src, dst, seek string) error {
63 +
	args := []string{"-nostdin", "-loglevel", "error", "-y"}
64 +
	if seek != "" {
65 +
		args = append(args, "-ss", seek)
66 +
	}
67 +
	args = append(args,
68 +
		"-i", src,
69 +
		"-frames:v", "1",
70 +
		"-vf", "scale=480:-2:force_original_aspect_ratio=decrease",
71 +
		"-q:v", "5",
72 +
		dst,
73 +
	)
74 +
	out, err := exec.CommandContext(ctx, "ffmpeg", args...).CombinedOutput()
75 +
	if err != nil {
76 +
		return fmt.Errorf("ffmpeg: %v: %s", err, bytes.TrimSpace(out))
77 +
	}
78 +
	return nil
79 +
}
hinzugefügt internal/web/web.go
+474 −0 Datei ansehen
@@ -0,0 +1,474 @@
1 +
// Package web stellt das Upload- und Sortier-Interface bereit.
2 +
package web
3 +
4 +
import (
5 +
	"crypto/sha1"
6 +
	"crypto/subtle"
7 +
	"encoding/hex"
8 +
	"encoding/json"
9 +
	"errors"
10 +
	"fmt"
11 +
	"io"
12 +
	"log"
13 +
	"mime/multipart"
14 +
	"net/http"
15 +
	"os"
16 +
	"path/filepath"
17 +
	"strings"
18 +
	"sync"
19 +
	"time"
20 +
21 +
	"ehc/tvwall/internal/config"
22 +
	"ehc/tvwall/internal/player"
23 +
	"ehc/tvwall/internal/playlist"
24 +
)
25 +
26 +
// Server buendelt alles, was die Handler brauchen.
27 +
type Server struct {
28 +
	cfg      *config.Config
29 +
	pl       *playlist.Manager
30 +
	player   *player.Player
31 +
	settings *config.Store
32 +
33 +
	thumbMu sync.Mutex
34 +
	hasFF   bool
35 +
}
36 +
37 +
// New erzeugt den Webserver.
38 +
func New(cfg *config.Config, pl *playlist.Manager, pp *player.Player, settings *config.Store) *Server {
39 +
	s := &Server{cfg: cfg, pl: pl, player: pp, settings: settings}
40 +
	if _, err := lookPath("ffmpeg"); err == nil {
41 +
		s.hasFF = true
42 +
	} else {
43 +
		log.Printf("web: ffmpeg nicht gefunden - Vorschaubilder fuer Videos sind deaktiviert")
44 +
	}
45 +
	return s
46 +
}
47 +
48 +
// Handler liefert den fertig verdrahteten HTTP-Handler.
49 +
func (s *Server) Handler() http.Handler {
50 +
	mux := http.NewServeMux()
51 +
52 +
	mux.HandleFunc("/", s.handleIndex)
53 +
	mux.Handle("/static/", http.StripPrefix("/static/", staticHandler()))
54 +
	mux.HandleFunc("/api/state", s.handleState)
55 +
	mux.HandleFunc("/api/upload", s.handleUpload)
56 +
	mux.HandleFunc("/api/order", s.handleOrder)
57 +
	mux.HandleFunc("/api/delete", s.handleDelete)
58 +
	mux.HandleFunc("/api/settings", s.handleSettings)
59 +
	mux.HandleFunc("/api/control", s.handleControl)
60 +
	mux.HandleFunc("/thumb/", s.handleThumb)
61 +
	mux.HandleFunc("/media/", s.handleMedia)
62 +
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
63 +
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
64 +
		io.WriteString(w, "ok\n")
65 +
	})
66 +
67 +
	return s.withAuth(mux)
68 +
}
69 +
70 +
// withAuth schuetzt alles per HTTP-Basic-Auth, sofern ein Passwort gesetzt ist.
71 +
func (s *Server) withAuth(next http.Handler) http.Handler {
72 +
	if s.cfg.WebPassword == "" {
73 +
		return next
74 +
	}
75 +
	want := []byte(s.cfg.WebPassword)
76 +
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77 +
		if r.URL.Path == "/healthz" {
78 +
			next.ServeHTTP(w, r)
79 +
			return
80 +
		}
81 +
		_, pass, ok := r.BasicAuth()
82 +
		if !ok || subtle.ConstantTimeCompare([]byte(pass), want) != 1 {
83 +
			w.Header().Set("WWW-Authenticate", `Basic realm="TV-Wall"`)
84 +
			http.Error(w, "Anmeldung erforderlich", http.StatusUnauthorized)
85 +
			return
86 +
		}
87 +
		next.ServeHTTP(w, r)
88 +
	})
89 +
}
90 +
91 +
// ---------------------------------------------------------------------------
92 +
// Handler
93 +
// ---------------------------------------------------------------------------
94 +
95 +
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
96 +
	if r.URL.Path != "/" {
97 +
		http.NotFound(w, r)
98 +
		return
99 +
	}
100 +
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
101 +
	w.Header().Set("Cache-Control", "no-store")
102 +
	writeAsset(w, "index.html")
103 +
}
104 +
105 +
type stateResponse struct {
106 +
	Entries  []playlist.Entry `json:"entries"`
107 +
	Version  uint64           `json:"version"`
108 +
	Status   player.Status    `json:"status"`
109 +
	Settings config.Settings  `json:"settings"`
110 +
	Device   deviceInfo       `json:"device"`
111 +
	Disk     diskInfo         `json:"disk"`
112 +
}
113 +
114 +
type deviceInfo struct {
115 +
	SSID      string `json:"ssid"`
116 +
	Hostname  string `json:"hostname"`
117 +
	Images    int    `json:"images"`
118 +
	Videos    int    `json:"videos"`
119 +
	Thumbs    bool   `json:"thumbs"`
120 +
	MaxUpload int64  `json:"max_upload_mb"`
121 +
}
122 +
123 +
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
124 +
	entries := s.pl.Entries()
125 +
	var images, videos int
126 +
	for _, e := range entries {
127 +
		if e.Kind == "video" {
128 +
			videos++
129 +
		} else {
130 +
			images++
131 +
		}
132 +
	}
133 +
	if entries == nil {
134 +
		entries = []playlist.Entry{}
135 +
	}
136 +
	free, total := diskUsage(s.cfg.MediaDir())
137 +
138 +
	writeJSON(w, http.StatusOK, stateResponse{
139 +
		Entries:  entries,
140 +
		Version:  s.pl.Version(),
141 +
		Status:   s.player.Status(),
142 +
		Settings: s.settings.Get(),
143 +
		Device: deviceInfo{
144 +
			SSID:      s.cfg.APSSID,
145 +
			Hostname:  s.cfg.Hostname,
146 +
			Images:    images,
147 +
			Videos:    videos,
148 +
			Thumbs:    s.hasFF,
149 +
			MaxUpload: s.cfg.MaxUploadMB,
150 +
		},
151 +
		Disk: diskInfo{Free: free, Total: total},
152 +
	})
153 +
}
154 +
155 +
type uploadResult struct {
156 +
	Uploaded []string `json:"uploaded"`
157 +
	Skipped  []string `json:"skipped"`
158 +
}
159 +
160 +
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
161 +
	if r.Method != http.MethodPost {
162 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
163 +
		return
164 +
	}
165 +
	maxBytes := s.cfg.MaxUploadMB << 20
166 +
	r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
167 +
168 +
	mr, err := r.MultipartReader()
169 +
	if err != nil {
170 +
		httpError(w, http.StatusBadRequest, "kein gueltiger Upload: "+err.Error())
171 +
		return
172 +
	}
173 +
174 +
	res := uploadResult{Uploaded: []string{}, Skipped: []string{}}
175 +
	for {
176 +
		part, err := mr.NextPart()
177 +
		if errors.Is(err, io.EOF) {
178 +
			break
179 +
		}
180 +
		if err != nil {
181 +
			httpError(w, http.StatusBadRequest, "Upload abgebrochen: "+err.Error())
182 +
			return
183 +
		}
184 +
		name := playlist.CleanUploadName(part.FileName())
185 +
		if name == "" {
186 +
			part.Close()
187 +
			continue
188 +
		}
189 +
		if !playlist.UploadAllowed(name) {
190 +
			res.Skipped = append(res.Skipped, name+" (Dateityp wird nicht unterstuetzt)")
191 +
			part.Close()
192 +
			continue
193 +
		}
194 +
		if playlist.Convertible(name) && !s.hasFF {
195 +
			res.Skipped = append(res.Skipped, name+" (HEIC braucht ffmpeg auf dem Pi)")
196 +
			part.Close()
197 +
			continue
198 +
		}
199 +
		saved, err := s.saveUpload(part, name)
200 +
		part.Close()
201 +
		if err != nil {
202 +
			log.Printf("web: upload %q: %v", name, err)
203 +
			res.Skipped = append(res.Skipped, name+" ("+err.Error()+")")
204 +
			continue
205 +
		}
206 +
		res.Uploaded = append(res.Uploaded, saved)
207 +
	}
208 +
209 +
	if len(res.Uploaded) > 0 {
210 +
		if _, err := s.pl.Scan(); err != nil {
211 +
			log.Printf("web: scan nach upload: %v", err)
212 +
		}
213 +
	}
214 +
	writeJSON(w, http.StatusOK, res)
215 +
}
216 +
217 +
// saveUpload schreibt den Datenstrom in eine temporaere Datei und benennt sie
218 +
// erst nach vollstaendigem Empfang um - so landen keine halben Dateien in der
219 +
// Playliste.
220 +
func (s *Server) saveUpload(part *multipart.Part, name string) (string, error) {
221 +
	dir := s.pl.MediaDir()
222 +
	if err := os.MkdirAll(dir, 0o775); err != nil {
223 +
		return "", err
224 +
	}
225 +
	tmp, err := os.CreateTemp(dir, ".upload-*")
226 +
	if err != nil {
227 +
		return "", err
228 +
	}
229 +
	tmpName := tmp.Name()
230 +
	defer os.Remove(tmpName)
231 +
232 +
	if _, err := io.Copy(tmp, part); err != nil {
233 +
		tmp.Close()
234 +
		return "", err
235 +
	}
236 +
	if err := tmp.Close(); err != nil {
237 +
		return "", err
238 +
	}
239 +
	// iPhone-Fotos kommen als HEIC - daraus wird ein JPEG, das mpv anzeigen kann.
240 +
	if playlist.Convertible(name) {
241 +
		converted := tmpName + ".jpg"
242 +
		if err := convertToJPEG(tmpName, converted); err != nil {
243 +
			return "", fmt.Errorf("HEIC umwandeln: %w", err)
244 +
		}
245 +
		defer os.Remove(converted)
246 +
		tmpName = converted
247 +
		name = strings.TrimSuffix(name, filepath.Ext(name)) + ".jpg"
248 +
	}
249 +
250 +
	if err := os.Chmod(tmpName, 0o664); err != nil {
251 +
		return "", err
252 +
	}
253 +
254 +
	final := playlist.UniqueName(dir, name)
255 +
	if err := os.Rename(tmpName, filepath.Join(dir, final)); err != nil {
256 +
		return "", err
257 +
	}
258 +
	return final, nil
259 +
}
260 +
261 +
func (s *Server) handleOrder(w http.ResponseWriter, r *http.Request) {
262 +
	if r.Method != http.MethodPost {
263 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
264 +
		return
265 +
	}
266 +
	var req struct {
267 +
		Order []string `json:"order"`
268 +
	}
269 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
270 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
271 +
		return
272 +
	}
273 +
	if err := s.pl.SetOrder(req.Order); err != nil {
274 +
		httpError(w, http.StatusInternalServerError, err.Error())
275 +
		return
276 +
	}
277 +
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
278 +
}
279 +
280 +
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
281 +
	if r.Method != http.MethodPost {
282 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
283 +
		return
284 +
	}
285 +
	var req struct {
286 +
		Name string `json:"name"`
287 +
	}
288 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
289 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
290 +
		return
291 +
	}
292 +
	if err := s.pl.Delete(req.Name); err != nil {
293 +
		httpError(w, http.StatusBadRequest, err.Error())
294 +
		return
295 +
	}
296 +
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
297 +
}
298 +
299 +
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
300 +
	if r.Method != http.MethodPost {
301 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
302 +
		return
303 +
	}
304 +
	cur := s.settings.Get()
305 +
	var req struct {
306 +
		ImageDuration *float64 `json:"image_duration"`
307 +
		Mute          *bool    `json:"mute"`
308 +
	}
309 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
310 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
311 +
		return
312 +
	}
313 +
	if req.ImageDuration != nil {
314 +
		cur.ImageDuration = *req.ImageDuration
315 +
	}
316 +
	if req.Mute != nil {
317 +
		cur.Mute = *req.Mute
318 +
	}
319 +
	saved, err := s.settings.Set(cur)
320 +
	if err != nil {
321 +
		httpError(w, http.StatusInternalServerError, err.Error())
322 +
		return
323 +
	}
324 +
	writeJSON(w, http.StatusOK, saved)
325 +
}
326 +
327 +
func (s *Server) handleControl(w http.ResponseWriter, r *http.Request) {
328 +
	if r.Method != http.MethodPost {
329 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
330 +
		return
331 +
	}
332 +
	var req struct {
333 +
		Action string `json:"action"`
334 +
	}
335 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
336 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
337 +
		return
338 +
	}
339 +
340 +
	var err error
341 +
	switch req.Action {
342 +
	case "next":
343 +
		err = s.player.Next()
344 +
	case "prev":
345 +
		err = s.player.Prev()
346 +
	case "pause":
347 +
		err = s.player.TogglePause()
348 +
	case "reload":
349 +
		err = s.player.ReloadNow()
350 +
	case "restart":
351 +
		err = s.player.Restart()
352 +
	case "rescan":
353 +
		_, err = s.pl.Scan()
354 +
	default:
355 +
		httpError(w, http.StatusBadRequest, "unbekannte Aktion")
356 +
		return
357 +
	}
358 +
	if err != nil {
359 +
		httpError(w, http.StatusConflict, err.Error())
360 +
		return
361 +
	}
362 +
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
363 +
}
364 +
365 +
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
366 +
	name, err := playlist.SafeName(strings.TrimPrefix(r.URL.Path, "/media/"))
367 +
	if err != nil {
368 +
		http.NotFound(w, r)
369 +
		return
370 +
	}
371 +
	path := filepath.Join(s.pl.MediaDir(), name)
372 +
	if !playlist.Supported(name) {
373 +
		http.NotFound(w, r)
374 +
		return
375 +
	}
376 +
	http.ServeFile(w, r, path)
377 +
}
378 +
379 +
// handleThumb liefert ein Vorschaubild und legt es dabei im Cache ab.
380 +
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
381 +
	name, err := playlist.SafeName(strings.TrimPrefix(r.URL.Path, "/thumb/"))
382 +
	if err != nil {
383 +
		http.NotFound(w, r)
384 +
		return
385 +
	}
386 +
	kind := playlist.Kind(name)
387 +
	if kind == "" {
388 +
		http.NotFound(w, r)
389 +
		return
390 +
	}
391 +
	src := filepath.Join(s.pl.MediaDir(), name)
392 +
	info, err := os.Stat(src)
393 +
	if err != nil {
394 +
		http.NotFound(w, r)
395 +
		return
396 +
	}
397 +
398 +
	if !s.hasFF {
399 +
		if kind == "image" {
400 +
			http.ServeFile(w, r, src)
401 +
			return
402 +
		}
403 +
		s.servePlaceholder(w)
404 +
		return
405 +
	}
406 +
407 +
	sum := sha1.Sum([]byte(fmt.Sprintf("%s|%d|%d", name, info.ModTime().UnixNano(), info.Size())))
408 +
	thumb := filepath.Join(s.cfg.ThumbDir(), hex.EncodeToString(sum[:])+".jpg")
409 +
410 +
	if _, err := os.Stat(thumb); err != nil {
411 +
		s.thumbMu.Lock()
412 +
		_, statErr := os.Stat(thumb)
413 +
		if statErr != nil {
414 +
			err = makeThumb(src, thumb, kind)
415 +
		} else {
416 +
			err = nil
417 +
		}
418 +
		s.thumbMu.Unlock()
419 +
		if err != nil {
420 +
			log.Printf("web: vorschaubild %q: %v", name, err)
421 +
			if kind == "image" {
422 +
				http.ServeFile(w, r, src)
423 +
			} else {
424 +
				s.servePlaceholder(w)
425 +
			}
426 +
			return
427 +
		}
428 +
	}
429 +
430 +
	w.Header().Set("Cache-Control", "public, max-age=86400")
431 +
	http.ServeFile(w, r, thumb)
432 +
}
433 +
434 +
func (s *Server) servePlaceholder(w http.ResponseWriter) {
435 +
	w.Header().Set("Content-Type", "image/svg+xml")
436 +
	w.Header().Set("Cache-Control", "public, max-age=86400")
437 +
	io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3">`+
438 +
		`<rect width="4" height="3" fill="#243040"/>`+
439 +
		`<path d="M1.6 1 2.8 1.5 1.6 2z" fill="#6f8cad"/></svg>`)
440 +
}
441 +
442 +
// ---------------------------------------------------------------------------
443 +
// Server starten
444 +
// ---------------------------------------------------------------------------
445 +
446 +
// ListenAndServe startet den HTTP-Server auf dem konfigurierten Port.
447 +
func (s *Server) ListenAndServe(addr string) error {
448 +
	srv := &http.Server{
449 +
		Addr:    addr,
450 +
		Handler: s.Handler(),
451 +
		// Kein ReadTimeout: grosse Videouploads duerfen lange dauern.
452 +
		ReadHeaderTimeout: 30 * time.Second,
453 +
		IdleTimeout:       120 * time.Second,
454 +
	}
455 +
	log.Printf("web: hoere auf %s", addr)
456 +
	return srv.ListenAndServe()
457 +
}
458 +
459 +
// ---------------------------------------------------------------------------
460 +
// Hilfsfunktionen
461 +
// ---------------------------------------------------------------------------
462 +
463 +
func writeJSON(w http.ResponseWriter, code int, v any) {
464 +
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
465 +
	w.Header().Set("Cache-Control", "no-store")
466 +
	w.WriteHeader(code)
467 +
	if err := json.NewEncoder(w).Encode(v); err != nil {
468 +
		log.Printf("web: antwort schreiben: %v", err)
469 +
	}
470 +
}
471 +
472 +
func httpError(w http.ResponseWriter, code int, msg string) {
473 +
	writeJSON(w, code, map[string]string{"error": msg})
474 +
}
hinzugefügt scripts/bisect-mpv.sh
+187 −0 Datei ansehen
@@ -0,0 +1,187 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  Findet heraus, WELCHE mpv-Option (oder welcher Ablauf) einen Haenger
4 +
#  ausloest. Statt zu raten wird jede Variante gemessen.
5 +
#
6 +
#  Aufruf:  sudo /opt/tvwall/scripts/bisect-mpv.sh /var/lib/tvwall/media/<video>
7 +
#
8 +
#  Der Bildschirm flackert waehrenddessen - das ist normal, jede Variante
9 +
#  spielt acht Sekunden. Der Dienst wird angehalten und danach neu gestartet.
10 +
# ===========================================================================
11 +
set -uo pipefail
12 +
13 +
CONF="/etc/tvwall/tvwall.conf"
14 +
LIMIT=25          # Sekunden, nach denen eine Variante als "haengt" gilt
15 +
PLAYSEC=8         # so lange soll jede Variante spielen
16 +
17 +
hdr()  { printf '\n\033[1;36m── %s ──────────────────────────\033[0m\n' "$*"; }
18 +
ok()   { printf '  \033[1;32mLAEUFT\033[0m  %-46s %ss\n' "$1" "$2"; }
19 +
bad()  { printf '  \033[1;31mHAENGT\033[0m  %-46s Abbruch nach %ss\n' "$1" "$2"; }
20 +
odd()  { printf '  \033[1;33mFEHLER\033[0m  %-46s Code %s\n' "$1" "$2"; }
21 +
note() { printf '    %s\n' "$*"; }
22 +
die()  { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
23 +
24 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten"
25 +
26 +
VIDEO="${1:-}"
27 +
[[ -n "$VIDEO" && -f "$VIDEO" ]] || die "Bitte eine Videodatei angeben:
28 +
       sudo $0 /var/lib/tvwall/media/<datei>.mp4"
29 +
30 +
RUN_USER="tvwall"; STATE_DIR="/var/lib/tvwall"; DISPLAY_MODE="auto"
31 +
[[ -f "$CONF" ]] && { set -a; . "$CONF" 2>/dev/null; set +a; }
32 +
RUN_USER="${RUN_USER:-tvwall}"; STATE_DIR="${STATE_DIR:-/var/lib/tvwall}"
33 +
RUN_UID="$(id -u "$RUN_USER" 2>/dev/null)" || die "Benutzer $RUN_USER gibt es nicht"
34 +
35 +
# --- Umgebung wie beim Dienst ---------------------------------------------
36 +
ENV_ARGS=(XDG_RUNTIME_DIR="/run/user/$RUN_UID")
37 +
VO_ARGS=(--vo=gpu)
38 +
if ! ls "/run/user/$RUN_UID"/wayland-* >/dev/null 2>&1 && [[ -z "${DISPLAY:-}" ]]; then
39 +
  VO_ARGS=(--vo=gpu --gpu-context=drm)      # kein Desktop -> direkt auf den Schirm
40 +
fi
41 +
42 +
# Die Optionen, die der Dienst setzt (ohne --idle/--loop, sonst endet nichts).
43 +
SERVICE_ARGS=(
44 +
  --fullscreen --force-window=yes --keep-open=no
45 +
  --image-display-duration=2
46 +
  --no-osc --osd-level=0 --cursor-autohide=always
47 +
  --no-input-default-bindings --input-vo-keyboard=no
48 +
  --stop-screensaver=yes --no-input-terminal
49 +
  "${VO_ARGS[@]}"
50 +
)
51 +
52 +
# --- Testmaterial ----------------------------------------------------------
53 +
IMAGE="$(find "$STATE_DIR/media" -maxdepth 1 -type f \
54 +
         \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) | sort | head -1)"
55 +
TMP="$(mktemp -d)"
56 +
trap 'rm -rf "$TMP"' EXIT
57 +
printf '%s\n' "$VIDEO" > "$TMP/nur-video.m3u"
58 +
if [[ -n "$IMAGE" ]]; then
59 +
  printf '%s\n%s\n' "$IMAGE" "$VIDEO" > "$TMP/bild-video.m3u"
60 +
fi
61 +
chmod -R a+rX "$TMP"
62 +
63 +
LOGDIR="$TMP/logs"; mkdir -p "$LOGDIR"
64 +
FAILED=()
65 +
declare -A RESULT
66 +
67 +
# Ein Durchlauf. LEN=0 laesst die Datei komplett laufen (ohne --length).
68 +
attempt() {
69 +
  local log="$1" limit="$2" len="$3"; shift 3
70 +
  local extra=()
71 +
  [[ "$len" != "0" ]] && extra=(--length="$len")
72 +
  sudo -u "$RUN_USER" env "${ENV_ARGS[@]}" \
73 +
    timeout -k 3 "$limit" mpv --no-config --msg-level=all=error "${extra[@]}" "$@" \
74 +
    >"$log" 2>&1
75 +
}
76 +
77 +
run_as() {
78 +
  local id="$1" label="$2" limit="$3" len="$4"; shift 4
79 +
  local log="$LOGDIR/$id.log"
80 +
  local start rc dur
81 +
  start=$(date +%s)
82 +
  attempt "$log" "$limit" "$len" "$@"
83 +
  rc=$?
84 +
  dur=$(( $(date +%s) - start ))
85 +
86 +
  # Fehlschlaege einmal wiederholen: der Compositor verweigert zwischen zwei
87 +
  # Laeufen gelegentlich kurz die Verbindung - das ist kein echter Befund.
88 +
  if [[ $rc -ne 0 ]]; then
89 +
    sleep 3
90 +
    attempt "$log" "$limit" "$len" "$@"
91 +
    local rc2=$?
92 +
    if [[ $rc2 -eq 0 ]]; then
93 +
      RESULT[$id]=ok
94 +
      printf '  \033[1;32mLAEUFT\033[0m  %-46s %ss (erster Versuch einmalig gescheitert)\n' "$id  $label" "$dur"
95 +
      return
96 +
    fi
97 +
    rc=$rc2
98 +
  fi
99 +
100 +
  case $rc in
101 +
    0)       RESULT[$id]=ok;   ok  "$id  $label" "$dur" ;;
102 +
    124|137) RESULT[$id]=hang; bad "$id  $label" "$dur"; FAILED+=("$id  $label|$log") ;;
103 +
    *)       RESULT[$id]=err;  odd "$id  $label" "$rc";  FAILED+=("$id  $label|$log") ;;
104 +
  esac
105 +
  sleep 2
106 +
}
107 +
108 +
run() { run_as "$1" "$2" "$LIMIT" "$PLAYSEC" "${@:3}"; }
109 +
110 +
hdr "Vorbereitung"
111 +
note "Datei:    $(basename "$VIDEO")"
112 +
note "Benutzer: $RUN_USER (uid $RUN_UID)"
113 +
note "Ausgabe:  ${VO_ARGS[*]}"
114 +
[[ -n "$IMAGE" ]] && note "Bild:     $(basename "$IMAGE")" || note "Bild:     keines gefunden - Uebergangstest entfaellt"
115 +
systemctl stop tvwall 2>/dev/null && note "Dienst angehalten"
116 +
sleep 1
117 +
118 +
hdr "Ist die Datei ueberhaupt vollstaendig?"
119 +
if command -v ffmpeg >/dev/null; then
120 +
  FFOUT="$(ffmpeg -v error -i "$VIDEO" -f null - 2>&1)"
121 +
  if [[ -z "$FFOUT" ]]; then
122 +
    printf '  \033[1;32mIN ORDNUNG\033[0m  komplett dekodierbar\n'
123 +
  else
124 +
    printf '  \033[1;31mBESCHAEDIGT\033[0m  ffmpeg meldet beim Durchlesen:\n'
125 +
    echo "$FFOUT" | head -6 | sed 's/^/      /'
126 +
    note "-> Dann hilft kein Einstellen: die Datei bitte neu hochladen."
127 +
  fi
128 +
  note "Groesse: $(stat -c %s "$VIDEO" 2>/dev/null) Bytes, md5: $(md5sum "$VIDEO" | cut -d" " -f1)"
129 +
fi
130 +
131 +
hdr "Einzeldatei"
132 +
run 1 "nur --fullscreen (mpv-Standard)"          --fullscreen "$VIDEO"
133 +
run 2 "Dienst-Optionen"                          "${SERVICE_ARGS[@]}" "$VIDEO"
134 +
run 3 "Dienst-Optionen + --hwdec=auto-safe"      "${SERVICE_ARGS[@]}" --hwdec=auto-safe "$VIDEO"
135 +
run 4 "Dienst-Optionen + --no-audio"             "${SERVICE_ARGS[@]}" --no-audio "$VIDEO"
136 +
run 5 "Dienst-Optionen + --vo=gpu-next"          "${SERVICE_ARGS[@]/--vo=gpu/--vo=gpu-next}" "$VIDEO"
137 +
138 +
hdr "Als Playliste (so macht es der Dienst)"
139 +
run 6 "Playliste mit nur dem Video"              "${SERVICE_ARGS[@]}" --playlist="$TMP/nur-video.m3u"
140 +
if [[ -n "$IMAGE" ]]; then
141 +
  run 7 "Playliste Bild -> Video"                "${SERVICE_ARGS[@]}" --playlist="$TMP/bild-video.m3u"
142 +
  run 8 "Bild -> Video, ohne --force-window"     "${SERVICE_ARGS[@]/--force-window=yes/--force-window=no}" \
143 +
                                                 --playlist="$TMP/bild-video.m3u"
144 +
  run 9 "Bild -> Video, mit --gpu-context=auto"  "${SERVICE_ARGS[@]}" --gpu-context=auto \
145 +
                                                 --playlist="$TMP/bild-video.m3u"
146 +
  # Ohne --length, damit ausgeschlossen ist, dass diese Testoption stoert.
147 +
  run_as 10 "Bild -> Video, komplett bis zum Ende" 120 0 \
148 +
            "${SERVICE_ARGS[@]}" --playlist="$TMP/bild-video.m3u"
149 +
fi
150 +
151 +
hdr "Ergebnis"
152 +
r() { echo "${RESULT[$1]:-}"; }
153 +
154 +
if [[ ${#FAILED[@]} -gt 0 ]]; then
155 +
  note "Haengen geblieben:"
156 +
  for entry in "${FAILED[@]}"; do
157 +
    printf '      %s\n' "${entry%%|*}"
158 +
    tail -5 "${entry##*|}" 2>/dev/null | sed 's/^/          /'
159 +
  done
160 +
  echo
161 +
fi
162 +
163 +
note "Deutung:"
164 +
if [[ "$(r 1)" != "ok" ]]; then
165 +
  note "  Schon der nackte mpv-Aufruf haengt. Dann liegt es an der Datei oder"
166 +
  note "  am Grafiktreiber, nicht an den Einstellungen dieses Werkzeugs."
167 +
elif [[ "$(r 2)" != "ok" ]]; then
168 +
  note "  Der nackte Aufruf laeuft, die Dienst-Optionen nicht. Die Varianten 3-5"
169 +
  note "  zeigen, welche Option es ist."
170 +
elif [[ -n "$IMAGE" && "$(r 7)" != "ok" && "$(r 6)" == "ok" ]]; then
171 +
  note "  Die Einzeldatei laeuft, der Wechsel Bild -> Video nicht."
172 +
  if [[ "$(r 10)" == "ok" ]]; then
173 +
    note "  Variante 10 (ohne --length) laeuft aber durch - dann stoert nur die"
174 +
    note "  Testoption --length, und der Dienst selbst ist nicht betroffen."
175 +
  else
176 +
    note "  Auch ohne Testoptionen (Variante 10) scheitert es - echter Befund."
177 +
    note "  Meldet ffmpeg oben \"BESCHAEDIGT\", ist die Datei die Ursache."
178 +
  fi
179 +
  [[ "$(r 8)" == "ok" ]] && note "  --force-window=no behebt es -> genau das laesst sich einstellen."
180 +
  [[ "$(r 9)" == "ok" ]] && note "  --gpu-context=auto behebt es -> genau das laesst sich einstellen."
181 +
elif [[ ${#FAILED[@]} -eq 0 ]]; then
182 +
  note "  Keine Variante haengt. Dann liegt es nicht an mpv, sondern am"
183 +
  note "  Zusammenspiel mit dem Dienst - bitte 'journalctl -u tvwall -n 60' schicken."
184 +
fi
185 +
186 +
systemctl start tvwall 2>/dev/null
187 +
hdr "Dienst wieder gestartet"
hinzugefügt scripts/diagnose.sh
+181 −0 Datei ansehen
@@ -0,0 +1,181 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  TV-Wall - Selbsttest. Sammelt alles, was man zur Fehlersuche braucht.
4 +
#
5 +
#  Aufruf:  sudo /opt/tvwall/scripts/diagnose.sh
6 +
# ===========================================================================
7 +
set -uo pipefail
8 +
9 +
CONF="${1:-/etc/tvwall/tvwall.conf}"
10 +
11 +
hdr()  { printf '\n\033[1;36m── %s ─────────────────────────────────────\033[0m\n' "$*"; }
12 +
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
13 +
bad()  { printf '\033[1;31m  ✗\033[0m %s\n' "$*"; }
14 +
warn() { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
15 +
note() { printf '    %s\n' "$*"; }
16 +
17 +
RUN_USER="tvwall"; DISPLAY_MODE="auto"; STATE_DIR="/var/lib/tvwall"; AP_SSID="?"
18 +
if [[ -f "$CONF" ]]; then
19 +
  set -a; . "$CONF" 2>/dev/null; set +a
20 +
fi
21 +
RUN_USER="${RUN_USER:-tvwall}"
22 +
DISPLAY_MODE="${DISPLAY_MODE:-auto}"
23 +
STATE_DIR="${STATE_DIR:-/var/lib/tvwall}"
24 +
25 +
hdr "System"
26 +
note "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")"
27 +
note "Kernel: $(uname -srm)"
28 +
note "Modell: $(tr -d '\0' < /proc/device-tree/model 2>/dev/null || echo unbekannt)"
29 +
note "Standard-Ziel: $(systemctl get-default 2>/dev/null)"
30 +
31 +
hdr "Konfiguration ($CONF)"
32 +
if [[ -f "$CONF" ]]; then
33 +
  note "DISPLAY_MODE = $DISPLAY_MODE"
34 +
  note "RUN_USER     = $RUN_USER"
35 +
  note "STATE_DIR    = $STATE_DIR"
36 +
  note "AP_SSID      = ${AP_SSID:-?}"
37 +
else
38 +
  bad "Konfigurationsdatei fehlt"
39 +
fi
40 +
41 +
hdr "Programme"
42 +
for p in mpv ffmpeg nmcli; do
43 +
  if command -v "$p" >/dev/null; then ok "$p: $(command -v "$p")"; else bad "$p fehlt"; fi
44 +
done
45 +
if [[ -x /usr/local/bin/tvwall ]]; then
46 +
  ok "tvwall: $(/usr/local/bin/tvwall -version 2>/dev/null || echo vorhanden)"
47 +
else
48 +
  bad "/usr/local/bin/tvwall fehlt"
49 +
fi
50 +
51 +
hdr "Dienst"
52 +
if systemctl is-active --quiet tvwall; then ok "tvwall laeuft"; else bad "tvwall laeuft NICHT"; fi
53 +
if systemctl is-enabled --quiet tvwall 2>/dev/null; then ok "Autostart aktiv"; else warn "Autostart nicht aktiv"; fi
54 +
note "Unit-Benutzer: $(systemctl show -p User --value tvwall 2>/dev/null)"
55 +
note "Neustarts:     $(systemctl show -p NRestarts --value tvwall 2>/dev/null)"
56 +
57 +
hdr "Benutzer $RUN_USER"
58 +
if id "$RUN_USER" >/dev/null 2>&1; then
59 +
  ok "$(id "$RUN_USER")"
60 +
  for g in video render input; do
61 +
    id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx "$g" \
62 +
      && ok "in Gruppe $g" || bad "NICHT in Gruppe $g"
63 +
  done
64 +
else
65 +
  bad "Benutzer existiert nicht"
66 +
fi
67 +
68 +
hdr "Bildschirm"
69 +
COMPOSITOR="$(pgrep -a -x 'labwc|wayfire|Xorg|Xwayland|weston|kwin_wayland' 2>/dev/null | head -3)"
70 +
if [[ -n "$COMPOSITOR" ]]; then
71 +
  warn "Es laeuft eine Desktop-Oberflaeche:"
72 +
  echo "$COMPOSITOR" | sed 's/^/      /'
73 +
  note "-> DISPLAY_MODE muss 'desktop' oder 'auto' sein, NICHT 'drm'."
74 +
else
75 +
  ok "keine Desktop-Oberflaeche aktiv (DRM/Konsole moeglich)"
76 +
fi
77 +
78 +
FOUND_SESSION=0
79 +
for d in /run/user/*; do
80 +
  [[ -d "$d" ]] || continue
81 +
  socks="$(ls "$d"/wayland-* 2>/dev/null | grep -v '\.lock$' | tr '\n' ' ')"
82 +
  [[ -z "$socks" ]] && continue
83 +
  FOUND_SESSION=1
84 +
  u="$(getent passwd "$(basename "$d")" | cut -d: -f1)"
85 +
  ok "Wayland-Sitzung von '$u': $socks"
86 +
  if [[ "$u" != "$RUN_USER" ]]; then
87 +
    bad "Der Dienst laeuft als '$RUN_USER', die Sitzung gehoert aber '$u'."
88 +
    note "-> In $CONF setzen:  RUN_USER=\"$u\"  und  DISPLAY_MODE=\"desktop\""
89 +
    note "-> danach:  sudo /opt/tvwall/install.sh --no-ap"
90 +
  fi
91 +
done
92 +
ls /tmp/.X11-unix/X* >/dev/null 2>&1 && { ok "X11-Sitzung vorhanden: $(ls /tmp/.X11-unix/)"; FOUND_SESSION=1; }
93 +
[[ $FOUND_SESSION -eq 0 ]] && note "keine Desktop-Sitzung gefunden"
94 +
95 +
for dev in /dev/dri/card*; do
96 +
  [[ -e "$dev" ]] && note "$(ls -l "$dev")"
97 +
done
98 +
99 +
hdr "Medien"
100 +
if [[ -d "$STATE_DIR/media" ]]; then
101 +
  COUNT="$(find "$STATE_DIR/media" -maxdepth 1 -type f | wc -l | tr -d ' ')"
102 +
  ok "$COUNT Datei(en) in $STATE_DIR/media"
103 +
  ls -lh "$STATE_DIR/media" 2>/dev/null | tail -n +2 | head -10 | sed 's/^/      /'
104 +
  [[ "$COUNT" == "0" ]] && warn "Ordner ist leer - dann bleibt der Bildschirm schwarz."
105 +
else
106 +
  bad "$STATE_DIR/media fehlt"
107 +
fi
108 +
[[ -f "$STATE_DIR/playlist.m3u" ]] && note "Playliste: $(($(wc -l < "$STATE_DIR/playlist.m3u") - 1)) Eintraege"
109 +
110 +
if command -v ffprobe >/dev/null; then
111 +
  echo
112 +
  note "Videodateien im Detail:"
113 +
  shopt -s nullglob nocaseglob
114 +
  for f in "$STATE_DIR"/media/*.{mp4,m4v,mov,mkv,avi,webm,mpg,mpeg,wmv,flv,ts,3gp,ogv}; do
115 +
    V="$(ffprobe -v error -select_streams v:0 \
116 +
         -show_entries stream=codec_name,width,height,avg_frame_rate \
117 +
         -of csv=p=0 "$f" 2>/dev/null)"
118 +
    D="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f" 2>/dev/null)"
119 +
    printf '      %-28s %s  %ss\n' "$(basename "$f")" "${V:-?}" "${D%%.*}"
120 +
    W="$(echo "$V" | cut -d, -f2)"
121 +
    C="$(echo "$V" | cut -d, -f1)"
122 +
    if [[ -n "$W" ]] && (( W > 1920 )); then
123 +
      warn "  ${W}px breit - fuer den Pi zu gross, bitte auf 1920 herunterrechnen"
124 +
    fi
125 +
    if [[ "$C" == "hevc" || "$C" == "h264" ]] && [[ "${MPV_HWDEC:-no}" != "no" ]]; then
126 +
      warn "  MPV_HWDEC=${MPV_HWDEC} - auf dem Pi 5 bleibt $C damit haengen."
127 +
      note "  -> MPV_HWDEC=\"no\" in $CONF setzen"
128 +
    fi
129 +
  done
130 +
  shopt -u nullglob nocaseglob
131 +
fi
132 +
133 +
hdr "Ton"
134 +
if command -v mpv >/dev/null && id "$RUN_USER" >/dev/null 2>&1; then
135 +
  RUN_UID="$(id -u "$RUN_USER")"
136 +
  DEVS="$(sudo -u "$RUN_USER" XDG_RUNTIME_DIR="/run/user/$RUN_UID" \
137 +
          timeout 10 mpv --audio-device=help 2>/dev/null | grep -v "^ *'auto'" | head -8)"
138 +
  if [[ -n "$DEVS" ]]; then
139 +
    ok "mpv sieht diese Audiogeraete:"
140 +
    echo "$DEVS" | sed 's/^/      /'
141 +
  else
142 +
    warn "mpv findet kein Audiogeraet - Videos mit Tonspur koennen dadurch haengen bleiben."
143 +
    note "-> In $CONF setzen:  AUDIO=\"no\""
144 +
  fi
145 +
  note "Aktuell eingestellt: AUDIO=${AUDIO:-yes}  MUTE=${MUTE:-no}"
146 +
fi
147 +
148 +
hdr "Webinterface"
149 +
PORT="${WEB_PORT:-80}"
150 +
CODE="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/healthz" 2>/dev/null)"
151 +
if [[ "$CODE" == "200" ]]; then
152 +
  ok "erreichbar auf Port $PORT"
153 +
  curl -s "http://127.0.0.1:$PORT/api/state" 2>/dev/null \
154 +
    | tr ',' '\n' | grep -E '"(running|display|current|last_error)"' | sed 's/^/      /'
155 +
else
156 +
  bad "nicht erreichbar auf Port $PORT (HTTP $CODE)"
157 +
fi
158 +
159 +
hdr "Access Point"
160 +
if command -v nmcli >/dev/null; then
161 +
  nmcli -t -f NAME,DEVICE,STATE connection show --active 2>/dev/null | sed 's/^/      /'
162 +
  nmcli -t -f NAME connection show --active 2>/dev/null | grep -qx tvwall-ap \
163 +
    && ok "tvwall-ap aktiv" || warn "tvwall-ap nicht aktiv"
164 +
fi
165 +
166 +
hdr "Letzte Protokollzeilen"
167 +
journalctl -u tvwall -n 40 --no-pager 2>/dev/null | sed 's/^/  /'
168 +
169 +
hdr "Direkter mpv-Test"
170 +
FIRST="$(find "$STATE_DIR/media" -maxdepth 1 -type f 2>/dev/null | head -1)"
171 +
if [[ -n "$FIRST" ]]; then
172 +
  note "So laesst sich mpv unabhaengig vom Dienst pruefen:"
173 +
  note "  sudo systemctl stop tvwall"
174 +
  if [[ -n "$COMPOSITOR" ]]; then
175 +
    note "  sudo -u $RUN_USER XDG_RUNTIME_DIR=/run/user/\$(id -u $RUN_USER) mpv --fullscreen '$FIRST'"
176 +
  else
177 +
    note "  sudo -u $RUN_USER mpv --vo=gpu --gpu-context=drm --fullscreen '$FIRST'"
178 +
  fi
179 +
  note "  sudo systemctl start tvwall"
180 +
fi
181 +
echo
hinzugefügt scripts/probe-transition.py
+170 −0 Datei ansehen
@@ -0,0 +1,170 @@
1 +
#!/usr/bin/env python3
2 +
"""
3 +
Prueft, ob ein Video in einem mpv-Prozess laeuft, der vorher ein Bild
4 +
angezeigt hat - und welche mpv-Option das gegebenenfalls behebt.
5 +
6 +
Genau dieser Uebergang ist der Grund, warum tvwall vor jedem Video einen
7 +
frischen mpv-Prozess startet (FRESH_MPV_FOR_VIDEO). Findet dieses Skript eine
8 +
Option, die den Uebergang heil macht, kann der Neustart entfallen.
9 +
10 +
Aufruf (Dienst vorher anhalten):
11 +
12 +
    sudo systemctl stop tvwall
13 +
    sudo -u rk XDG_RUNTIME_DIR=/run/user/1000 \\
14 +
         python3 probe-transition.py <bild> <video>
15 +
    sudo systemctl start tvwall
16 +
"""
17 +
import json
18 +
import os
19 +
import socket
20 +
import subprocess
21 +
import sys
22 +
import tempfile
23 +
import time
24 +
25 +
BASE = [
26 +
    "--fullscreen", "--idle=yes", "--force-window=yes", "--keep-open=no",
27 +
    "--image-display-duration=2", "--hwdec=no", "--no-osc", "--osd-level=0",
28 +
    "--cursor-autohide=always", "--no-input-default-bindings",
29 +
    "--input-vo-keyboard=no", "--no-input-terminal", "--msg-level=all=error",
30 +
    "--vo=gpu",
31 +
]
32 +
33 +
VARIANTEN = [
34 +
    ("Grundzustand (wie der Dienst)",        []),
35 +
    ("--demuxer-thread=no",                  ["--demuxer-thread=no"]),
36 +
    ("--cache=no",                           ["--cache=no"]),
37 +
    ("--cache=yes",                          ["--cache=yes"]),
38 +
    ("ohne Zusatzdateien-Suche",             ["--audio-file-auto=no", "--sub-auto=no",
39 +
                                              "--cover-art-auto=no"]),
40 +
    ("--vd-lavc-dr=no",                      ["--vd-lavc-dr=no"]),
41 +
    ("--demuxer-lavf-probe-info=nostreams",  ["--demuxer-lavf-probe-info=nostreams"]),
42 +
    ("--gapless-audio=no",                   ["--gapless-audio=no"]),
43 +
    ("--force-window=no",                    ["--force-window=no"]),
44 +
    ("--no-audio",                           ["--no-audio"]),
45 +
]
46 +
47 +
48 +
class Mpv:
49 +
    """Minimaler Client fuer den JSON-IPC-Socket von mpv."""
50 +
51 +
    def __init__(self, path):
52 +
        self.sock = socket.socket(socket.AF_UNIX)
53 +
        for _ in range(80):
54 +
            try:
55 +
                self.sock.connect(path)
56 +
                break
57 +
            except OSError:
58 +
                time.sleep(0.25)
59 +
        else:
60 +
            raise RuntimeError("mpv-Socket nicht erreichbar")
61 +
        self.buf = b""
62 +
        self.rid = 0
63 +
64 +
    def command(self, *args):
65 +
        self.rid += 1
66 +
        rid = self.rid
67 +
        self.sock.sendall(
68 +
            (json.dumps({"command": list(args), "request_id": rid}) + "\n").encode())
69 +
        deadline = time.time() + 5
70 +
        while time.time() < deadline:
71 +
            self.sock.settimeout(max(0.1, deadline - time.time()))
72 +
            try:
73 +
                chunk = self.sock.recv(65536)
74 +
            except (socket.timeout, OSError):
75 +
                break
76 +
            if not chunk:
77 +
                break
78 +
            self.buf += chunk
79 +
            while b"\n" in self.buf:
80 +
                line, self.buf = self.buf.split(b"\n", 1)
81 +
                try:
82 +
                    msg = json.loads(line)
83 +
                except ValueError:
84 +
                    continue
85 +
                if msg.get("request_id") == rid:
86 +
                    return msg.get("data")
87 +
        return None
88 +
89 +
    def close(self):
90 +
        try:
91 +
            self.sock.close()
92 +
        except OSError:
93 +
            pass
94 +
95 +
96 +
def probe(extra, image, video):
97 +
    """Bild laden, dann Video - und schauen, ob das Video wirklich laeuft."""
98 +
    tmp = tempfile.mkdtemp()
99 +
    sock = os.path.join(tmp, "s")
100 +
    proc = subprocess.Popen(
101 +
        ["mpv", "--input-ipc-server=" + sock] + BASE + extra,
102 +
        stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
103 +
    try:
104 +
        m = Mpv(sock)
105 +
        m.command("loadfile", image, "replace")
106 +
        time.sleep(2.5)
107 +
        m.command("loadfile", video, "replace")
108 +
        time.sleep(2.0)
109 +
        erst = m.command("get_property", "time-pos")
110 +
        time.sleep(3.0)
111 +
        spaeter = m.command("get_property", "time-pos")
112 +
        m.close()
113 +
114 +
        if not isinstance(erst, (int, float)) or not isinstance(spaeter, (int, float)):
115 +
            return False, "Video wurde gar nicht erst geladen"
116 +
        if spaeter - erst < 1.0:
117 +
            return False, "steht bei %.1f s" % spaeter
118 +
        return True, "laeuft bis %.1f s" % spaeter
119 +
    finally:
120 +
        proc.kill()
121 +
        try:
122 +
            err = proc.stderr.read().decode(errors="replace").strip()
123 +
        except Exception:
124 +
            err = ""
125 +
        proc.wait()
126 +
        if err:
127 +
            probe.last_error = err.splitlines()[-3:]
128 +
        else:
129 +
            probe.last_error = []
130 +
131 +
132 +
def main():
133 +
    if len(sys.argv) != 3:
134 +
        print(__doc__)
135 +
        sys.exit(1)
136 +
    image, video = sys.argv[1], sys.argv[2]
137 +
    for f in (image, video):
138 +
        if not os.path.isfile(f):
139 +
            print("Datei fehlt:", f)
140 +
            sys.exit(1)
141 +
142 +
    print("\nBild : %s\nVideo: %s\n" % (image, video))
143 +
    print("Uebergang Bild -> Video im selben mpv-Prozess:\n")
144 +
145 +
    gut = []
146 +
    for name, extra in VARIANTEN:
147 +
        ok, info = probe(extra, image, video)
148 +
        print("  %-38s %s  (%s)" % (name, "LAEUFT" if ok else "HAENGT", info))
149 +
        for zeile in getattr(probe, "last_error", []):
150 +
            print("      %s" % zeile)
151 +
        if ok:
152 +
            gut.append(name)
153 +
154 +
    print()
155 +
    if not gut:
156 +
        print("Keine Option hilft. Der Neustart vor jedem Video")
157 +
        print("(FRESH_MPV_FOR_VIDEO=\"yes\") bleibt die richtige Loesung.")
158 +
    elif gut == [n for n, _ in VARIANTEN]:
159 +
        print("Alle Varianten laufen - der Uebergang ist auf diesem System heil.")
160 +
        print("Dann kann FRESH_MPV_FOR_VIDEO=\"no\" gesetzt werden.")
161 +
    else:
162 +
        print("Diese Optionen beheben den Uebergang:")
163 +
        for n in gut:
164 +
            print("   ", n)
165 +
        print("\nTrag die passende in MPV_EXTRA_ARGS ein und setze")
166 +
        print("FRESH_MPV_FOR_VIDEO=\"no\" - dann entfaellt der Neustart.")
167 +
168 +
169 +
if __name__ == "__main__":
170 +
    main()
hinzugefügt scripts/setup-ap.sh
+126 −0 Datei ansehen
@@ -0,0 +1,126 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  Richtet den Raspberry Pi als WLAN-Access-Point ein (NetworkManager/nmcli).
4 +
#
5 +
#  Aufruf:  sudo ./setup-ap.sh [/etc/tvwall/tvwall.conf]
6 +
#
7 +
#  Der Name des WLANs kommt aus AP_SSID in der Konfigurationsdatei - so
8 +
#  bekommt jeder Pi seinen eigenen, unterscheidbaren Netznamen.
9 +
# ===========================================================================
10 +
set -euo pipefail
11 +
12 +
CONF="${1:-/etc/tvwall/tvwall.conf}"
13 +
CON_NAME="tvwall-ap"
14 +
15 +
info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
16 +
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
17 +
warn() { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
18 +
die()  { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
19 +
20 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten:  sudo $0"
21 +
[[ -f "$CONF" ]]  || die "Konfigurationsdatei nicht gefunden: $CONF"
22 +
23 +
set -a
24 +
# shellcheck disable=SC1090
25 +
. "$CONF"
26 +
set +a
27 +
28 +
AP_SSID="${AP_SSID:-TVWall-01}"
29 +
AP_PASSWORD="${AP_PASSWORD:-tvwall1234}"
30 +
AP_COUNTRY="${AP_COUNTRY:-DE}"
31 +
AP_IP="${AP_IP:-192.168.4.1}"
32 +
AP_CHANNEL="${AP_CHANNEL:-7}"
33 +
CAPTIVE_PORTAL="${CAPTIVE_PORTAL:-no}"
34 +
HOSTNAME_NEW="${HOSTNAME:-tvwall}"
35 +
36 +
[[ ${#AP_PASSWORD} -ge 8 ]] || die "AP_PASSWORD muss mindestens 8 Zeichen haben"
37 +
38 +
command -v nmcli >/dev/null || die "nmcli fehlt. Bitte NetworkManager installieren:
39 +
       sudo apt install network-manager && sudo systemctl enable --now NetworkManager"
40 +
41 +
systemctl is-active --quiet NetworkManager || systemctl start NetworkManager
42 +
43 +
# --- WLAN-Schnittstelle finden --------------------------------------------
44 +
IFACE="${AP_IFACE:-}"
45 +
if [[ -z "$IFACE" ]]; then
46 +
  IFACE="$(nmcli -t -f DEVICE,TYPE device status | awk -F: '$2=="wifi"{print $1; exit}')"
47 +
fi
48 +
[[ -n "$IFACE" ]] || die "keine WLAN-Schnittstelle gefunden"
49 +
info "WLAN-Schnittstelle: $IFACE"
50 +
51 +
# --- Funk freischalten und Land setzen ------------------------------------
52 +
command -v rfkill >/dev/null && rfkill unblock wifi || true
53 +
if command -v raspi-config >/dev/null; then
54 +
  raspi-config nonint do_wifi_country "$AP_COUNTRY" >/dev/null 2>&1 || true
55 +
fi
56 +
command -v iw >/dev/null && iw reg set "$AP_COUNTRY" >/dev/null 2>&1 || true
57 +
nmcli radio wifi on >/dev/null 2>&1 || true
58 +
ok "Funk aktiv, Land: $AP_COUNTRY"
59 +
60 +
# --- Verbindung anlegen ----------------------------------------------------
61 +
info "Richte Access Point '$AP_SSID' ein"
62 +
nmcli connection delete "$CON_NAME" >/dev/null 2>&1 || true
63 +
64 +
nmcli connection add \
65 +
  type wifi \
66 +
  ifname "$IFACE" \
67 +
  con-name "$CON_NAME" \
68 +
  autoconnect yes \
69 +
  ssid "$AP_SSID" >/dev/null
70 +
71 +
nmcli connection modify "$CON_NAME" \
72 +
  802-11-wireless.mode ap \
73 +
  802-11-wireless.band bg \
74 +
  802-11-wireless.channel "$AP_CHANNEL" \
75 +
  802-11-wireless.hidden no \
76 +
  wifi-sec.key-mgmt wpa-psk \
77 +
  wifi-sec.proto rsn \
78 +
  wifi-sec.pairwise ccmp \
79 +
  wifi-sec.group ccmp \
80 +
  wifi-sec.psk "$AP_PASSWORD" \
81 +
  ipv4.method shared \
82 +
  ipv4.addresses "$AP_IP/24" \
83 +
  ipv6.method disabled \
84 +
  connection.autoconnect yes \
85 +
  connection.autoconnect-priority 100
86 +
87 +
# --- Namensaufloesung im eigenen Netz -------------------------------------
88 +
# NetworkManager startet im Modus "shared" ein eigenes dnsmasq. Damit ist der
89 +
# Pi auch unter einem einfachen Namen statt nur ueber die IP erreichbar.
90 +
install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d
91 +
{
92 +
  echo "# von TV-Wall setup-ap.sh erzeugt"
93 +
  echo "address=/tvwall/$AP_IP"
94 +
  echo "address=/$HOSTNAME_NEW/$AP_IP"
95 +
  echo "address=/$HOSTNAME_NEW.local/$AP_IP"
96 +
  if [[ "$CAPTIVE_PORTAL" == "yes" ]]; then
97 +
    # Alle Namen zeigen auf den Pi - Handys oeffnen die Seite dann von selbst.
98 +
    echo "address=/#/$AP_IP"
99 +
  fi
100 +
} > /etc/NetworkManager/dnsmasq-shared.d/tvwall.conf
101 +
ok "Namensaufloesung eingerichtet (http://tvwall)"
102 +
103 +
# --- Starten ---------------------------------------------------------------
104 +
info "Aktiviere Access Point"
105 +
if ! nmcli connection up "$CON_NAME" >/dev/null 2>&1; then
106 +
  warn "Access Point liess sich nicht sofort starten. Details:"
107 +
  nmcli connection up "$CON_NAME" || true
108 +
  warn "Haeufigste Ursache: das WLAN ist noch mit einem anderen Netz verbunden."
109 +
  warn "Andere WLAN-Verbindungen abschalten:  nmcli connection show"
110 +
fi
111 +
112 +
sleep 2
113 +
if nmcli -t -f NAME connection show --active | grep -qx "$CON_NAME"; then
114 +
  ok "Access Point '$AP_SSID' laeuft auf $IFACE ($AP_IP)"
115 +
else
116 +
  warn "Access Point ist noch nicht aktiv - nach einem Neustart versucht NetworkManager es erneut"
117 +
fi
118 +
119 +
cat <<EOF
120 +
121 +
   WLAN-Name  : $AP_SSID
122 +
   Passwort   : $AP_PASSWORD
123 +
   Adresse    : http://$AP_IP   (oder http://tvwall)
124 +
125 +
   Namen aendern: AP_SSID in $CONF anpassen und dieses Skript erneut ausfuehren.
126 +
EOF
hinzugefügt scripts/uninstall.sh
+29 −0 Datei ansehen
@@ -0,0 +1,29 @@
1 +
#!/usr/bin/env bash
2 +
# Entfernt TV-Wall wieder. Medien bleiben erhalten, sofern nicht --purge.
3 +
set -euo pipefail
4 +
[[ $EUID -eq 0 ]] || { echo "bitte mit sudo starten" >&2; exit 1; }
5 +
6 +
PURGE=0
7 +
[[ "${1:-}" == "--purge" ]] && PURGE=1
8 +
9 +
systemctl disable --now tvwall.service 2>/dev/null || true
10 +
rm -f /etc/systemd/system/tvwall.service
11 +
systemctl daemon-reload
12 +
13 +
nmcli connection delete tvwall-ap 2>/dev/null || true
14 +
rm -f /etc/NetworkManager/dnsmasq-shared.d/tvwall.conf
15 +
16 +
rm -f /usr/local/bin/tvwall
17 +
rm -rf /opt/tvwall
18 +
19 +
# Login auf der Konsole wieder einschalten.
20 +
systemctl enable --now getty@tty1.service 2>/dev/null || true
21 +
22 +
if [[ $PURGE -eq 1 ]]; then
23 +
  rm -rf /var/lib/tvwall /etc/tvwall
24 +
  userdel -r tvwall 2>/dev/null || true
25 +
  echo "TV-Wall vollstaendig entfernt (inklusive Medien)."
26 +
else
27 +
  echo "TV-Wall entfernt. Medien und Konfiguration bleiben erhalten:"
28 +
  echo "  /var/lib/tvwall  und  /etc/tvwall"
29 +
fi
hinzugefügt systemd/tvwall-desktop.service.in
+30 −0 Datei ansehen
@@ -0,0 +1,30 @@
1 +
# TV-Wall - Wiedergabe im laufenden Desktop (Raspberry Pi OS mit Desktop).
2 +
# Wird von install.sh nach /etc/systemd/system/tvwall.service geschrieben.
3 +
[Unit]
4 +
Description=TV-Wall Media-Player und Webinterface
5 +
Documentation=file:/opt/tvwall/README.md
6 +
After=network.target graphical.target
7 +
8 +
[Service]
9 +
Type=simple
10 +
User=@RUN_USER@
11 +
SupplementaryGroups=video render audio input
12 +
13 +
# Der Player haengt sich an die Desktop-Sitzung des Benutzers. Welcher
14 +
# Wayland- bzw. X11-Zugang das genau ist, sucht tvwall beim Start selbst.
15 +
Environment=XDG_RUNTIME_DIR=/run/user/@RUN_UID@
16 +
17 +
ExecStart=/usr/local/bin/tvwall -config @CONFIG@
18 +
Restart=always
19 +
RestartSec=3
20 +
21 +
AmbientCapabilities=CAP_NET_BIND_SERVICE
22 +
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
23 +
24 +
RuntimeDirectory=tvwall
25 +
RuntimeDirectoryMode=0775
26 +
27 +
NoNewPrivileges=yes
28 +
29 +
[Install]
30 +
WantedBy=graphical.target
hinzugefügt systemd/tvwall-drm.service.in
+38 −0 Datei ansehen
@@ -0,0 +1,38 @@
1 +
# TV-Wall - Wiedergabe direkt auf der Konsole (Raspberry Pi OS Lite).
2 +
# Wird von install.sh nach /etc/systemd/system/tvwall.service geschrieben.
3 +
[Unit]
4 +
Description=TV-Wall Media-Player und Webinterface
5 +
Documentation=file:/opt/tvwall/README.md
6 +
After=network.target getty@tty1.service
7 +
Conflicts=getty@tty1.service
8 +
9 +
[Service]
10 +
Type=simple
11 +
User=@RUN_USER@
12 +
SupplementaryGroups=video render audio input tty
13 +
14 +
# Bildschirm gehoert uns: Konsole tty1, kein Bildschirmschoner, kein Cursor.
15 +
TTYPath=/dev/tty1
16 +
StandardInput=tty-force
17 +
StandardOutput=journal
18 +
StandardError=journal
19 +
TTYVTDisallocate=yes
20 +
ExecStartPre=/bin/sh -c '/usr/bin/setterm --blank 0 --powerdown 0 --cursor off > /dev/tty1 || true'
21 +
22 +
ExecStart=/usr/local/bin/tvwall -config @CONFIG@
23 +
Restart=always
24 +
RestartSec=3
25 +
26 +
# Damit der Webserver Port 80 verwenden darf, ohne als root zu laufen.
27 +
AmbientCapabilities=CAP_NET_BIND_SERVICE
28 +
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
29 +
30 +
# /run/tvwall fuer den mpv-Steuersocket
31 +
RuntimeDirectory=tvwall
32 +
RuntimeDirectoryMode=0775
33 +
34 +
NoNewPrivileges=yes
35 +
ProtectSystem=full
36 +
37 +
[Install]
38 +
WantedBy=multi-user.target