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:
34
.github/copilot-instructions.md
vendored
34
.github/copilot-instructions.md
vendored
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ For detailed deployment instructions, see:
|
|||||||
- Website and WebUntis events share a unified payload:
|
- Website and WebUntis events share a unified payload:
|
||||||
- `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
|
||||||
|
|||||||
@@ -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 2–4)",
|
|
||||||
"🔧 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."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
28
nginx.conf
28
nginx.conf
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user