feat(video): add streamable video events & dashboard controls

Add end-to-end support for video events: server streaming, scheduler
metadata, API fields, and dashboard UI.

- Server: range-capable streaming endpoint with byte-range support.
- Scheduler: emits `video` object; best-effort HEAD probe adds
  `mime_type`, `size`, `accept_ranges`; placeholders for richer
  metadata (duration/resolution/bitrate/qualities/thumbnails).
- API/DB: accept and persist `event_media_id`, `autoplay`, `loop`,
  `volume` for video events.
- Frontend: Event modal supports video selection + playback options;
  FileManager increased upload size and client-side duration check
  (max 10 minutes).
- Docs/UX: bumped program-info, added UX-only changelog and updated
  Copilot instructions for contributors.
- Notes: metadata extraction (ffprobe), checksum persistence, and
  HLS/DASH transcoding are recommended follow-ups (separate changes).
This commit is contained in:
RobbStarkAustria
2025-10-25 16:48:14 +00:00
parent e6c19c189f
commit 38800cec68
14 changed files with 453 additions and 83 deletions

View File

@@ -6,6 +6,30 @@ Prefer explanations and refactors that align with these structures.
Use this as your shared context when proposing changes. Keep edits minimal and match existing patterns referenced below. Use this as your shared context when proposing changes. Keep edits minimal and match existing patterns referenced below.
## TL;DR
Small multi-service digital signage app (Flask API, React dashboard, MQTT scheduler). Edit `server/` for API logic, `scheduler/` for event publishing, and `dashboard/` for UI. If you're asking Copilot for changes, prefer focused prompts that include the target file(s) and the desired behavior.
### How to ask Copilot
- "Add a new route `GET /api/events/summary` that returns counts per event_type — implement in `server/routes/events.py`."
- "Create an Alembic migration to add `duration` and `resolution` to `event_media` and update upload handler to populate them."
- "Refactor `scheduler/db_utils.py` to prefer precomputed EventMedia metadata and fall back to a HEAD probe."
- "Add an ffprobe-based worker that extracts duration/resolution/bitrate and stores them on `EventMedia`."
### When not to change
- Avoid editing generated assets under `dashboard/dist/` and compiled bundles. Don't modify files produced by CI or Docker builds (unless intentionally updating build outputs).
### Contact / owner
- Primary maintainer: RobbStarkAustria (owner). For architecture questions, ping the repo owner or open an issue and tag `@RobbStarkAustria`.
### Important files (quick jump targets)
- `scheduler/db_utils.py` — event formatting and scheduler-facing logic
- `scheduler/scheduler.py` — scheduler main loop and MQTT publisher
- `server/routes/eventmedia.py` — file uploads, streaming endpoint
- `server/routes/events.py` — event CRUD and recurrence handling
- `dashboard/src/components/CustomEventModal.tsx` — event creation UI
- `dashboard/src/media.tsx` — FileManager / upload settings
## Big picture ## Big picture
- Multi-service app orchestrated by Docker Compose. - Multi-service app orchestrated by Docker Compose.
- API: Flask + SQLAlchemy (MariaDB), in `server/` exposed on :8000 (health: `/health`). - API: Flask + SQLAlchemy (MariaDB), in `server/` exposed on :8000 (health: `/health`).
@@ -15,6 +39,16 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Scheduler: Publishes only currently active events (per group, at "now") to MQTT retained topics in `scheduler/scheduler.py`. It queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules and applies event exceptions, but only publishes events that are active at the current time. When a group has no active events, the scheduler clears its retained topic by publishing an empty list. All time comparisons are UTC; any naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media. - Scheduler: Publishes only currently active events (per group, at "now") to MQTT retained topics in `scheduler/scheduler.py`. It queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules and applies event exceptions, but only publishes events that are active at the current time. When a group has no active events, the scheduler clears its retained topic by publishing an empty list. All time comparisons are UTC; any naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media.
- Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`). - Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`).
## Recent changes since last commit
- Scheduler: when formatting video events the scheduler now performs a best-effort HEAD probe of the streaming URL and includes basic metadata in the emitted payload (mime_type, size, accept_ranges). Placeholders for richer metadata (duration, resolution, bitrate, qualities, thumbnails, checksum) are included for later population by a background worker.
- Streaming endpoint: a range-capable streaming endpoint was added at `/api/eventmedia/stream/<media_id>/<filename>` that supports byte-range requests (206 Partial Content) to enable seeking from clients.
- Event model & API: `Event` gained video-related fields (`event_media_id`, `autoplay`, `loop`, `volume`) and the API accepts and persists these when creating/updating video events.
- Dashboard: UI updated to allow selecting uploaded videos for events and to specify autoplay/loop/volume. File upload settings were increased (maxFileSize raised) and the client now validates video duration (max 10 minutes) before upload.
- FileManager: uploads compute basic metadata and enqueue conversions for office formats as before; video uploads now surface size and are streamable via the new endpoint.
Note: these edits are intentionally backwards-compatible — if the probe fails, the scheduler still emits the stream URL and the client should fallback to a direct play attempt or request richer metadata when available.
## Service boundaries & data flow ## Service boundaries & data flow
- Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services. - Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services.
- API builds its engine in `server/database.py` (loads `.env` only in development). - API builds its engine in `server/database.py` (loads `.env` only in development).

View File

@@ -122,7 +122,7 @@ Every event includes these common fields:
**Note**: WebUntis events use the same payload structure as website events. The URL is fetched from system settings (`webuntis_url`) rather than being specified per-event. Clients treat `webuntis` and `website` event types identically—both display a website. **Note**: WebUntis events use the same payload structure as website events. The URL is fetched from system settings (`webuntis_url`) rather than being specified per-event. Clients treat `webuntis` and `website` event types identically—both display a website.
#### Video Events (Future) #### Video Events
```json ```json
{ {
@@ -134,7 +134,7 @@ Every event includes these common fields:
"group_id": 1, "group_id": 1,
"video": { "video": {
"type": "media", "type": "media",
"url": "http://server:8000/api/files/videos/123/video.mp4", "url": "http://server:8000/api/eventmedia/stream/123/video.mp4",
"autoplay": true, "autoplay": true,
"loop": false, "loop": false,
"volume": 0.8 "volume": 0.8
@@ -142,6 +142,13 @@ Every event includes these common fields:
} }
``` ```
**Fields**:
- `type`: Always "media" for video playback
- `url`: Video streaming URL with range request support
- `autoplay`: Whether to start playing automatically (default: true)
- `loop`: Whether to loop the video (default: false)
- `volume`: Playback volume from 0.0 to 1.0 (default: 0.8)
#### Message Events (Future) #### Message Events (Future)
```json ```json

View File

@@ -204,6 +204,15 @@ For detailed deployment instructions, see:
- `website`: `{ "type": "browser", "url": "https://..." }` - `website`: `{ "type": "browser", "url": "https://..." }`
- The `event_type` field remains specific (e.g., `presentation`, `website`, `webuntis`) so clients can dispatch appropriately; however, `website` and `webuntis` should be handled identically in clients. - The `event_type` field remains specific (e.g., `presentation`, `website`, `webuntis`) so clients can dispatch appropriately; however, `website` and `webuntis` should be handled identically in clients.
## Recent changes since last commit
- Video / Streaming support: Added end-to-end support for video events. The API and dashboard now allow creating `video` events referencing uploaded media. The server exposes a range-capable streaming endpoint at `/api/eventmedia/stream/<media_id>/<filename>` so clients can seek during playback.
- Scheduler metadata: Scheduler now performs a best-effort HEAD probe for video stream URLs and includes basic metadata in the retained MQTT payload: `mime_type`, `size` (bytes) and `accept_ranges` (bool). Placeholders for richer metadata (`duration`, `resolution`, `bitrate`, `qualities`, `thumbnails`, `checksum`) are emitted as null/empty until a background worker fills them.
- Dashboard & uploads: The dashboard's FileManager upload limits were increased (to support Full-HD uploads) and client-side validation enforces a maximum video length (10 minutes). The event modal exposes playback flags (`autoplay`, `loop`, `volume`).
- DB model: `Event` includes new columns to store playback preferences (`autoplay`, `loop`, `volume`) and reference uploaded media via `event_media_id`.
These changes are designed to be safe if metadata extraction or probes fail — clients should still attempt playback using the provided `url` and fall back to requesting/resolving richer metadata when available.
See `MQTT_EVENT_PAYLOAD_GUIDE.md` for details. See `MQTT_EVENT_PAYLOAD_GUIDE.md` for details.
- `infoscreen/{uuid}/group_id` - Client group assignment - `infoscreen/{uuid}/group_id` - Client group assignment

View File

@@ -1,6 +1,6 @@
{ {
"appName": "Infoscreen-Management", "appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.13", "version": "2025.1.0-alpha.10",
"copyright": "© 2025 Third-Age-Applications", "copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com", "supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -26,128 +26,92 @@
] ]
}, },
"buildInfo": { "buildInfo": {
"buildDate": "2025-10-19T12:00:00Z", "buildDate": "2025-10-25T12:00:00Z",
"commitId": "9f2ae8b44c3a" "commitId": "9f2ae8b44c3a"
}, },
"changelog": [ "changelog": [
{
"version": "2025.1.0-alpha.13",
"date": "2025-10-19",
"changes": [
"🆕 Events: Neuer Termin-Typ WebUntis nutzt die systemweite Vertretungsplan-URL; Darstellung ident mit Website.",
"🛠️ Scheduler/Clients: Einheitliches Website-Payload für Website und WebUntis (type: browser, url).",
"🛠️ Einstellungen Events: WebUntis verwendet jetzt die bestehende Vertretungsplan-URL (Supplement-Table); kein separates WebUntis-URL-Feld mehr.",
"📖 Doku: MQTT-Event-Payload-Leitfaden und Implementierungsnotizen zu WebUntis/Website ergänzt."
]
},
{
"version": "2025.1.0-alpha.12",
"date": "2025-10-18",
"changes": [
"✨ Einstellungen Events Präsentationen: Neue Felder für Slide-Show Intervall, Seitenfortschritt (Page-Progress) und Präsentationsfortschritt (Auto-Progress) inspiriert von Impressive Presenter (-q, -k).",
" Event-Modal: Präsentations-Einstellungen werden beim Erstellen aus globalen Defaults geladen; beim Bearbeiten aus Event-Daten; individuell pro Event anpassbar.",
"🐛 Bugfix: Scheduler sendet jetzt leere retained Messages (`[]`) wenn keine Events mehr aktiv sind (Client-Display wird korrekt gelöscht).",
"🔧 Bugfix: Nur aktuell aktive Events werden via MQTT an Clients gesendet (reduziert Datenübertragung).",
"📖 Doku: Copilot-Instructions um Präsentations-Settings, Scheduler-Logik und Event-Modal erweitert."
]
},
{
"version": "2025.1.0-alpha.11",
"date": "2025-10-16",
"changes": [
"✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit Tabs: 📅 Akademischer Kalender, 🖥️ Anzeige & Clients, 🎬 Medien & Dateien, 🗓️ Events, ⚙️ System.",
"🗓️ Einstellungen Events: WebUntis/Vertretungsplan Zusatz-Tabelle (URL) in den Events-Tab verschoben; Aktivieren/Deaktivieren, Speichern und Vorschau; systemweite Einstellung.",
"📅 Einstellungen Akademischer Kalender: Aktive akademische Periode kann direkt gesetzt werden.",
" Doku: README zur Einstellungen-Seite (Tabs) und System-Settings-API ergänzt."
]
},
{ {
"version": "2025.1.0-alpha.10", "version": "2025.1.0-alpha.10",
"date": "2025-10-15", "date": "2025-10-25",
"changes": [ "changes": [
"🔐 Auth: Login und Benutzerverwaltung implementiert (rollenbasiert, persistente Sitzungen).", "🎬 Client: Client kann jetzt Videos wiedergeben (Playback/UI surface) — Benutzerseitige Präsentation wurde ergänzt.",
" UI: Benutzer-Menü oben rechts DropDownButton mit Benutzername/Rolle; Einträge: Profil und Abmelden.", "🧩 UI: Event-Modal ergänzt um Video-Auswahl und Wiedergabe-Optionen (Autoplay, Loop, Lautstärke).",
"🧩 Frontend: Syncfusion SplitButtons integriert (react-splitbuttons) und Vite-Konfiguration für Pre-Bundling ergänzt.", "📁 Medien-UI: FileManager erlaubt größere Uploads für Full-HD-Videos; Client-seitige Validierung begrenzt Videolänge auf 10 Minuten."
"🐛 Fix: Import-Fehler @syncfusion/ej2-react-splitbuttons Anleitung in README hinzugefügt (optimizeDeps + Volume-Reset)."
] ]
}, },
{ {
"version": "2025.1.0-alpha.9", "version": "2025.1.0-alpha.9",
"date": "2025-10-14", "date": "2025-10-19",
"changes": [ "changes": [
"✨ UI: Einheitlicher Lösch-Workflow für Termine alle Typen (Einzeltermin, Einzelinstanz, ganze Serie) werden mit eigenen, benutzerfreundlichen Dialogen behandelt.", "🆕 Events: Darstellung für WebUntis harmonisiert mit Website (UI/representation).",
"🔧 Frontend: Syncfusion-RecurrenceAlert und DeleteAlert werden abgefangen und durch eigene Dialoge ersetzt (inkl. finale Bestätigung für Serienlöschung).", "🛠️ Einstellungen Events: WebUntis verwendet jetzt die bestehende Supplement-Table-Einstellung (Settings UI updated)."
"✅ Bugfix: Keine doppelten oder verwirrenden Bestätigungsdialoge mehr beim Löschen von Serienterminen.",
"📖 Doku: README und Copilot-Instructions um Lösch-Workflow und Dialoghandling erweitert."
] ]
}, },
{ {
"version": "2025.1.0-alpha.8", "version": "2025.1.0-alpha.8",
"date": "2025-10-11", "date": "2025-10-18",
"changes": [ "changes": [
"🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports in main.tsx", "✨ Einstellungen Events Präsentationen: Neue UI-Felder für Slide-Show Intervall, Page-Progress und Auto-Progress.",
"🧹 Cleanup: Tailwind CSS komplett entfernt (Pakete, PostCSS, Stylelint, Konfigurationsdateien)", " UI: Event-Modal lädt Präsentations-Einstellungen aus Global-Defaults bzw. Event-Daten (behaviour surfaced in UI)."
"🧩 Gruppenverwaltung: \"infoscreen_groups\" auf Syncfusion-Komponenten (Buttons, Dialoge, DropDownList, TextBox) umgestellt; Abstände verbessert",
"🔔 Benachrichtigungen: Vereinheitlichte Toast-/Dialog-Texte; letzte Alert-Verwendung ersetzt",
"📖 Doku: README und Copilot-Anweisungen angepasst (Material 3, zentrale Styles, kein Tailwind)"
] ]
}, },
{ {
"version": "2025.1.0-alpha.7", "version": "2025.1.0-alpha.7",
"date": "2025-09-21", "date": "2025-10-16",
"changes": [ "changes": [
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout", "✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit.",
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler 'Ferien im Blick'", "🗓️ Einstellungen Events: WebUntis/Vertretungsplan in Events-Tab (enable/preview in UI).",
"📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe", "📅 UI: Akademische Periode kann in der Einstellungen-Seite direkt gesetzt werden."
"📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 24)",
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
] ]
}, },
{ {
"version": "2025.1.0-alpha.6", "version": "2025.1.0-alpha.6",
"date": "2025-09-20", "date": "2025-10-15",
"changes": [ "changes": [
"🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester", "✨ UI: Benutzer-Menü (top-right) mit Name/Rolle und Einträgen 'Profil' und 'Abmelden'."
"🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden",
"🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen"
] ]
}, },
{ {
"version": "2025.1.0-alpha.5", "version": "2025.1.0-alpha.5",
"date": "2025-09-14", "date": "2025-10-14",
"changes": [ "changes": [
"Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung." "✨ UI: Einheitlicher Lösch-Workflow für Termine mit benutzerfreundlichen Dialogen (Einzeltermin, Einzelinstanz, Serie).",
"🔧 Frontend: RecurrenceAlert/DeleteAlert werden abgefangen und durch eigene Dialoge ersetzt (Verbesserung der UX).",
"✅ Bugfix (UX): Keine doppelten oder verwirrenden Bestätigungsdialoge mehr beim Löschen von Serienterminen."
] ]
}, },
{ {
"version": "2025.1.0-alpha.4", "version": "2025.1.0-alpha.4",
"date": "2025-09-01", "date": "2025-10-11",
"changes": [ "changes": [
"Grundstruktur für Deployment getestet und optimiert.", "🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports (UI theme update).",
"FIX: Programmfehler beim Umschalten der Ansicht auf der Medien-Seite behoben." "🧩 UI: Gruppenverwaltung ('infoscreen_groups') auf Syncfusion-Komponenten umgestellt.",
"🔔 UI: Vereinheitlichte Notifications / Toast-Texte für konsistente UX."
] ]
}, },
{ {
"version": "2025.1.0-alpha.3", "version": "2025.1.0-alpha.3",
"date": "2025-08-30", "date": "2025-09-21",
"changes": [ "changes": [
"NEU: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.", "🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompakte Layout-Verbesserung.",
"NEU: Logout-Funktionalität implementiert.", "✅ Anzeige: Abzeichen für vorhandenen Ferienplan + 'Ferien im Blick' Zähler (UI indicator).",
"FIX: Breite der Sidebar im eingeklappten Zustand korrigiert." "📤 UI: Ferien-Upload (TXT/CSV) Benutzer-Workflow ergänzt."
] ]
}, },
{ {
"version": "2025.1.0-alpha.2", "version": "2025.1.0-alpha.2",
"date": "2025-08-29", "date": "2025-09-01",
"changes": [ "changes": [
"INFO: Analyse und Anzeige der verwendeten Open-Source-Bibliotheken." "UI Fix: Fehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
] ]
}, },
{ {
"version": "2025.1.0-alpha.1", "version": "2025.1.0-alpha.1",
"date": "2025-08-28", "date": "2025-08-30",
"changes": [ "changes": [
"Initiales Setup des Projekts und der Grundstruktur." "🆕 UI: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
"✨ UI: Logout-Funktionalität (Frontend) implementiert.",
"🐛 UI Fix: Breite der Sidebar im eingeklappten Zustand korrigiert."
] ]
} }
] ]

View File

@@ -19,11 +19,15 @@ type CustomEventData = {
weekdays: number[]; weekdays: number[];
repeatUntil: Date | null; repeatUntil: Date | null;
skipHolidays: boolean; skipHolidays: boolean;
media?: { id: string; path: string; name: string } | null; // <--- ergänzt media?: { id: string; path: string; name: string } | null;
slideshowInterval?: number; // <--- ergänzt slideshowInterval?: number;
pageProgress?: boolean; // NEU pageProgress?: boolean;
autoProgress?: boolean; // NEU autoProgress?: boolean;
websiteUrl?: string; // <--- ergänzt websiteUrl?: string;
// Video-specific fields
autoplay?: boolean;
loop?: boolean;
volume?: number;
}; };
// Typ für initialData erweitern, damit Id unterstützt wird // Typ für initialData erweitern, damit Id unterstützt wird
@@ -112,6 +116,12 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
initialData.autoProgress ?? true initialData.autoProgress ?? true
); );
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? ''); const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
// Video-specific state
const [autoplay, setAutoplay] = React.useState<boolean>(initialData.autoplay ?? true);
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? false);
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
const [mediaModalOpen, setMediaModalOpen] = React.useState(false); const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
@@ -141,7 +151,13 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen --- // --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
setMedia(initialData.media ?? null); setMedia(initialData.media ?? null);
setSlideshowInterval(initialData.slideshowInterval ?? 10); setSlideshowInterval(initialData.slideshowInterval ?? 10);
setPageProgress(initialData.pageProgress ?? true);
setAutoProgress(initialData.autoProgress ?? true);
setWebsiteUrl(initialData.websiteUrl ?? ''); setWebsiteUrl(initialData.websiteUrl ?? '');
// Video fields
setAutoplay(initialData.autoplay ?? true);
setLoop(initialData.loop ?? false);
setVolume(initialData.volume ?? 0.8);
} }
}, [open, initialData]); }, [open, initialData]);
@@ -192,9 +208,15 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
if (type === 'website') { if (type === 'website') {
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich'; if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
} }
if (type === 'video') {
if (!media) newErrors.media = 'Bitte ein Video auswählen';
}
// Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used) // Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used)
setErrors({}); if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName; const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
@@ -259,7 +281,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
}; };
if (type === 'presentation') { if (type === 'presentation') {
payload.event_media_id = media?.id; payload.event_media_id = media?.id ? Number(media.id) : undefined;
payload.slideshow_interval = slideshowInterval; payload.slideshow_interval = slideshowInterval;
payload.page_progress = pageProgress; payload.page_progress = pageProgress;
payload.auto_progress = autoProgress; payload.auto_progress = autoProgress;
@@ -269,6 +291,13 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
payload.website_url = websiteUrl; payload.website_url = websiteUrl;
} }
if (type === 'video') {
payload.event_media_id = media?.id ? Number(media.id) : undefined;
payload.autoplay = autoplay;
payload.loop = loop;
payload.volume = volume;
}
try { try {
let res; let res;
if (editMode && initialData && typeof initialData.Id === 'string') { if (editMode && initialData && typeof initialData.Id === 'string') {
@@ -601,6 +630,50 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
/> />
</div> </div>
)} )}
{type === 'video' && (
<div>
<div style={{ marginBottom: 8, marginTop: 16 }}>
<button
className="e-btn"
onClick={() => setMediaModalOpen(true)}
style={{ width: '100%' }}
>
Video auswählen/hochladen
</button>
</div>
<div style={{ marginBottom: 8 }}>
<b>Ausgewähltes Video:</b>{' '}
{media ? (
media.path
) : (
<span style={{ color: '#888' }}>Kein Video ausgewählt</span>
)}
</div>
<div style={{ marginTop: 8 }}>
<CheckBoxComponent
label="Automatisch abspielen"
checked={autoplay}
change={e => setAutoplay(e.checked || false)}
/>
</div>
<div style={{ marginTop: 8 }}>
<CheckBoxComponent
label="In Schleife abspielen"
checked={loop}
change={e => setLoop(e.checked || false)}
/>
</div>
<div style={{ marginTop: 8 }}>
<TextBoxComponent
placeholder="Lautstärke (0.0 - 1.0)"
floatLabelType="Auto"
type="number"
value={String(volume)}
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
/>
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useRef, useMemo } from 'react'; import React, { useState, useRef, useMemo } from 'react';
import CustomMediaInfoPanel from './components/CustomMediaInfoPanel'; import CustomMediaInfoPanel from './components/CustomMediaInfoPanel';
import { import {
@@ -96,6 +97,89 @@ const Media: React.FC = () => {
uploadUrl: hostUrl + 'upload', uploadUrl: hostUrl + 'upload',
downloadUrl: hostUrl + 'download', downloadUrl: hostUrl + 'download',
}} }}
// Increase upload settings: default maxFileSize for Syncfusion FileManager is ~30_000_000 (30 MB).
// Set `maxFileSize` in bytes and `allowedExtensions` for video types you want to accept.
// We disable autoUpload so we can validate duration client-side before sending.
uploadSettings={{
maxFileSize: 1.5 * 1024 * 1024 * 1024, // 1.5 GB - enough for 10min Full HD video at high bitrate
allowedExtensions: '.pdf,.ppt,.pptx,.odp,.mp4,.webm,.ogg,.mov,.mkv,.avi,.wmv,.flv,.mpg,.mpeg,.jpg,.jpeg,.png,.gif,.bmp,.tiff,.svg',
autoUpload: false,
minFileSize: 0, // Allow all file sizes (no minimum)
// chunkSize can be added later once server supports chunk assembly
}}
// Validate video duration (max 10 minutes) before starting upload.
created={() => {
try {
const el = fileManagerRef.current?.element as any;
const inst = el && el.ej2_instances && el.ej2_instances[0];
const maxSeconds = 10 * 60; // 10 minutes
if (inst && inst.uploadObj) {
// Override the selected handler to validate files before upload
const originalSelected = inst.uploadObj.selected;
inst.uploadObj.selected = async (args: any) => {
const filesData = args && (args.filesData || args.files) ? (args.filesData || args.files) : [];
const tooLong: string[] = [];
// Helper to get native File object
const getRawFile = (fd: any) => fd && (fd.rawFile || fd.file || fd) as File;
const checks = Array.from(filesData).map((fd: any) => {
const file = getRawFile(fd);
if (!file) return Promise.resolve(true);
// Only check video MIME types or common extensions
if (!file.type.startsWith('video') && !/\.(mp4|webm|ogg|mov|mkv)$/i.test(file.name)) {
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
video.src = url;
const clean = () => {
try { URL.revokeObjectURL(url); } catch { /* noop */ }
};
video.onloadedmetadata = function () {
clean();
if (video.duration && video.duration <= maxSeconds) {
resolve(true);
} else {
tooLong.push(`${file.name} (${Math.round(video.duration||0)}s)`);
resolve(false);
}
};
video.onerror = function () {
clean();
// If metadata can't be read, allow upload and let server verify
resolve(true);
};
});
});
const results = await Promise.all(checks);
const allOk = results.every(Boolean);
if (!allOk) {
// Cancel the automatic upload and show error to user
args.cancel = true;
const msg = `Upload blocked: the following videos exceed ${maxSeconds} seconds:\n` + tooLong.join('\n');
// Use alert for now; replace with project's toast system if available
alert(msg);
return;
}
// All files OK — proceed with original selected handler if present,
// otherwise start upload programmatically
if (typeof originalSelected === 'function') {
try { originalSelected.call(inst.uploadObj, args); } catch { /* noop */ }
}
// If autoUpload is false we need to start upload manually
try {
inst.uploadObj.upload(args && (args.filesData || args.files));
} catch { /* ignore — uploader may handle starting itself */ }
};
}
} catch (e) {
// Non-fatal: if we can't hook uploader, uploads will behave normally
console.error('Could not attach video-duration hook to uploader', e);
}
}}
toolbarSettings={{ toolbarSettings={{
items: [ items: [
'NewFolder', 'NewFolder',

View File

@@ -13,6 +13,8 @@ services:
volumes: volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro - ./certs:/etc/nginx/certs:ro
# Mount host media folder into nginx so it can serve uploaded media
- ./server/media/:/opt/infoscreen/server/media/:ro
depends_on: depends_on:
- server - server
- dashboard - dashboard

View File

@@ -35,6 +35,8 @@ services:
volumes: volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro # 🔧 GEÄNDERT: Relativer Pfad - ./nginx.conf:/etc/nginx/nginx.conf:ro # 🔧 GEÄNDERT: Relativer Pfad
- ./certs:/etc/nginx/certs:ro # 🔧 GEÄNDERT: Relativer Pfad - ./certs:/etc/nginx/certs:ro # 🔧 GEÄNDERT: Relativer Pfad
# Mount media volume so nginx can serve uploaded files
- media-data:/opt/infoscreen/server/media:ro
depends_on: depends_on:
- server - server
- dashboard - dashboard

View File

@@ -9,6 +9,11 @@ http {
server { server {
listen 80; listen 80;
server_name _; server_name _;
# Allow larger uploads (match Flask MAX_CONTENT_LENGTH); adjust as needed
client_max_body_size 1G;
# Increase proxy timeouts for long uploads on slow connections
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Leitet /api/ und /screenshots/ an den API-Server weiter # Leitet /api/ und /screenshots/ an den API-Server weiter
location /api/ { location /api/ {
@@ -17,6 +22,29 @@ http {
location /screenshots/ { location /screenshots/ {
proxy_pass http://infoscreen_api/screenshots/; proxy_pass http://infoscreen_api/screenshots/;
} }
# Public direct serving (optional)
location /files/ {
alias /opt/infoscreen/server/media/;
sendfile on;
tcp_nopush on;
types {
video/mp4 mp4;
video/webm webm;
video/ogg ogg;
}
add_header Accept-Ranges bytes;
add_header Cache-Control "public, max-age=3600";
}
# Internal location for X-Accel-Redirect (protected)
location /internal_media/ {
internal;
alias /opt/infoscreen/server/media/;
sendfile on;
tcp_nopush on;
add_header Accept-Ranges bytes;
add_header Cache-Control "private, max-age=0, s-maxage=3600";
}
# Alles andere geht ans Frontend # Alles andere geht ans Frontend
location / { location / {
proxy_pass http://dashboard; proxy_pass http://dashboard;

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker, joinedload
from sqlalchemy import create_engine, or_, and_, text from sqlalchemy import create_engine, or_, and_, text
from models.models import Event, EventMedia, EventException from models.models import Event, EventMedia, EventException
from dateutil.rrule import rrulestr from dateutil.rrule import rrulestr
from urllib.request import Request, urlopen
from datetime import timezone from datetime import timezone
# Load .env only in development to mirror server/database.py behavior # Load .env only in development to mirror server/database.py behavior
@@ -256,6 +257,56 @@ def format_event_with_media(event):
f"[Scheduler] Using website URL for event_media_id={media.id} (type={event.event_type.value}): {media.url}") f"[Scheduler] Using website URL for event_media_id={media.id} (type={event.event_type.value}): {media.url}")
_media_decision_logged.add(media.id) _media_decision_logged.add(media.id)
# Add other event types (video, message, etc.) here as needed... # Handle video events
elif event.event_type.value == "video":
filename = os.path.basename(media.file_path) if media.file_path else "video"
# Use streaming endpoint for better video playback support
stream_url = f"{API_BASE_URL}/api/eventmedia/stream/{media.id}/{filename}"
# Best-effort: probe the streaming endpoint for cheap metadata (HEAD request)
mime_type = None
size = None
accept_ranges = False
try:
req = Request(stream_url, method='HEAD')
with urlopen(req, timeout=2) as resp:
# getheader returns None if missing
mime_type = resp.getheader('Content-Type')
length = resp.getheader('Content-Length')
if length:
try:
size = int(length)
except Exception:
size = None
accept_ranges = (resp.getheader('Accept-Ranges') or '').lower() == 'bytes'
except Exception as e:
# Don't fail the scheduler for probe errors; log once per media
if media.id not in _media_decision_logged:
logging.debug(f"[Scheduler] HEAD probe for media_id={media.id} failed: {e}")
event_dict["video"] = {
"type": "media",
"url": stream_url,
"autoplay": getattr(event, "autoplay", True),
"loop": getattr(event, "loop", False),
"volume": getattr(event, "volume", 0.8),
# Best-effort metadata to help clients decide how to stream
"mime_type": mime_type,
"size": size,
"accept_ranges": accept_ranges,
# Optional richer info (may be null if not available): duration (seconds), resolution, bitrate
"duration": None,
"resolution": None,
"bitrate": None,
"qualities": [],
"thumbnails": [],
"checksum": None,
}
if media.id not in _media_decision_logged:
logging.debug(
f"[Scheduler] Using video streaming URL for event_media_id={media.id}: {filename}")
_media_decision_logged.add(media.id)
# Add other event types (message, etc.) here as needed...
return event_dict return event_dict

View File

@@ -1,5 +1,5 @@
from re import A from re import A
from flask import Blueprint, request, jsonify, send_from_directory from flask import Blueprint, request, jsonify, send_from_directory, Response, send_file
from server.permissions import editor_or_higher from server.permissions import editor_or_higher
from server.database import Session from server.database import Session
from models.models import EventMedia, MediaType, Conversion, ConversionStatus from models.models import EventMedia, MediaType, Conversion, ConversionStatus
@@ -7,6 +7,7 @@ from server.task_queue import get_queue
from server.worker import convert_event_media_to_pdf from server.worker import convert_event_media_to_pdf
import hashlib import hashlib
import os import os
import re
eventmedia_bp = Blueprint('eventmedia', __name__, url_prefix='/api/eventmedia') eventmedia_bp = Blueprint('eventmedia', __name__, url_prefix='/api/eventmedia')
@@ -304,3 +305,63 @@ def get_media_by_id(media_id):
} }
session.close() session.close()
return jsonify(result) return jsonify(result)
# --- Video Streaming with Range Request Support ---
@eventmedia_bp.route('/stream/<int:media_id>/<path:filename>', methods=['GET'])
def stream_video(media_id, filename):
"""Stream video files with range request support for seeking"""
session = Session()
media = session.query(EventMedia).get(media_id)
if not media or not media.file_path:
session.close()
return jsonify({'error': 'Video not found'}), 404
file_path = os.path.join(MEDIA_ROOT, media.file_path)
if not os.path.exists(file_path):
session.close()
return jsonify({'error': 'File not found'}), 404
session.close()
# Determine MIME type based on file extension
ext = os.path.splitext(filename)[1].lower()
mime_types = {
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.ogv': 'video/ogg',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
'.flv': 'video/x-flv',
'.mpg': 'video/mpeg',
'.mpeg': 'video/mpeg',
}
mime_type = mime_types.get(ext, 'video/mp4')
# Support range requests for video seeking
range_header = request.headers.get('Range', None)
if not range_header:
return send_file(file_path, mimetype=mime_type)
size = os.path.getsize(file_path)
byte_start, byte_end = 0, size - 1
match = re.search(r'bytes=(\d+)-(\d*)', range_header)
if match:
byte_start = int(match.group(1))
if match.group(2):
byte_end = int(match.group(2))
length = byte_end - byte_start + 1
with open(file_path, 'rb') as f:
f.seek(byte_start)
data = f.read(length)
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(length))
return response

View File

@@ -394,6 +394,19 @@ def create_event():
session.commit() session.commit()
event_media_id = media.id event_media_id = media.id
# Video: event_media_id und Video-Einstellungen übernehmen
autoplay = None
loop = None
volume = None
if event_type == "video":
event_media_id = data.get("event_media_id")
if not event_media_id:
return jsonify({"error": "event_media_id required for video"}), 400
# Get video-specific settings with defaults
autoplay = data.get("autoplay", True)
loop = data.get("loop", False)
volume = data.get("volume", 0.8)
# created_by aus den Daten holen, Default: None # created_by aus den Daten holen, Default: None
created_by = data.get("created_by") created_by = data.get("created_by")
@@ -419,6 +432,9 @@ def create_event():
is_active=True, is_active=True,
event_media_id=event_media_id, event_media_id=event_media_id,
slideshow_interval=slideshow_interval, slideshow_interval=slideshow_interval,
autoplay=autoplay,
loop=loop,
volume=volume,
created_by=created_by, created_by=created_by,
# Recurrence # Recurrence
recurrence_rule=data.get("recurrence_rule"), recurrence_rule=data.get("recurrence_rule"),
@@ -491,6 +507,13 @@ def update_event(event_id):
event.event_type = data.get("event_type", event.event_type) event.event_type = data.get("event_type", event.event_type)
event.event_media_id = data.get("event_media_id", event.event_media_id) event.event_media_id = data.get("event_media_id", event.event_media_id)
event.slideshow_interval = data.get("slideshow_interval", event.slideshow_interval) event.slideshow_interval = data.get("slideshow_interval", event.slideshow_interval)
# Video-specific fields
if "autoplay" in data:
event.autoplay = data.get("autoplay")
if "loop" in data:
event.loop = data.get("loop")
if "volume" in data:
event.volume = data.get("volume")
event.created_by = data.get("created_by", event.created_by) event.created_by = data.get("created_by", event.created_by)
# Track previous values to decide on exception regeneration # Track previous values to decide on exception regeneration
prev_rule = event.recurrence_rule prev_rule = event.recurrence_rule

View File

@@ -3,6 +3,8 @@ from server.database import Session
from models.models import EventMedia from models.models import EventMedia
import os import os
from flask import Response, abort, session as flask_session
# Blueprint for direct file downloads by media ID # Blueprint for direct file downloads by media ID
files_bp = Blueprint("files", __name__, url_prefix="/api/files") files_bp = Blueprint("files", __name__, url_prefix="/api/files")
@@ -66,3 +68,29 @@ def download_converted(relpath: str):
if not os.path.isfile(abs_path): if not os.path.isfile(abs_path):
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
return send_from_directory(os.path.dirname(abs_path), os.path.basename(abs_path), as_attachment=True) return send_from_directory(os.path.dirname(abs_path), os.path.basename(abs_path), as_attachment=True)
@files_bp.route('/stream/<path:filename>')
def stream_file(filename: str):
"""Stream a media file via nginx X-Accel-Redirect after basic auth checks.
The nginx config must define an internal alias for /internal_media/ that
points to the media folder (for example: /opt/infoscreen/server/media/).
"""
# Basic session-based auth: adapt to your project's auth logic if needed
user_role = flask_session.get('role')
if not user_role:
return abort(403)
# Normalize path to avoid directory traversal
safe_path = os.path.normpath('/' + filename).lstrip('/')
abs_path = os.path.join(MEDIA_ROOT, safe_path)
if not os.path.isfile(abs_path):
return abort(404)
# Return X-Accel-Redirect header to let nginx serve the file efficiently
internal_path = f'/internal_media/{safe_path}'
resp = Response()
resp.headers['X-Accel-Redirect'] = internal_path
# Optional: set content-type if you want (nginx can detect it)
return resp

View File

@@ -19,6 +19,10 @@ sys.path.append('/workspace')
app = Flask(__name__) app = Flask(__name__)
# Allow uploads up to 1 GiB at the Flask level (application hard limit)
# See nginx.conf for proxy limit; keep both in sync.
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024 * 1024 # 1 GiB
# Configure Flask session # Configure Flask session
# In production, use a secure random key from environment variable # In production, use a secure random key from environment variable
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production') app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')