diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e99d3b1..3b900e7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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. +## 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 - Multi-service app orchestrated by Docker Compose. - 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. - 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//` 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 - 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). diff --git a/MQTT_EVENT_PAYLOAD_GUIDE.md b/MQTT_EVENT_PAYLOAD_GUIDE.md index 603580e..6b8a528 100644 --- a/MQTT_EVENT_PAYLOAD_GUIDE.md +++ b/MQTT_EVENT_PAYLOAD_GUIDE.md @@ -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. -#### Video Events (Future) +#### Video Events ```json { @@ -134,7 +134,7 @@ Every event includes these common fields: "group_id": 1, "video": { "type": "media", - "url": "http://server:8000/api/files/videos/123/video.mp4", + "url": "http://server:8000/api/eventmedia/stream/123/video.mp4", "autoplay": true, "loop": false, "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) ```json diff --git a/README.md b/README.md index 046f0a2..483ee47 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,15 @@ For detailed deployment instructions, see: - Website and WebUntis events share a unified payload: - `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. + +## 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//` 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. - `infoscreen/{uuid}/group_id` - Client group assignment diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index ba51fd1..0342459 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -1,6 +1,6 @@ { "appName": "Infoscreen-Management", - "version": "2025.1.0-alpha.13", + "version": "2025.1.0-alpha.10", "copyright": "© 2025 Third-Age-Applications", "supportContact": "support@third-age-applications.com", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", @@ -26,128 +26,92 @@ ] }, "buildInfo": { - "buildDate": "2025-10-19T12:00:00Z", + "buildDate": "2025-10-25T12:00:00Z", "commitId": "9f2ae8b44c3a" }, "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", - "date": "2025-10-15", + "date": "2025-10-25", "changes": [ - "🔐 Auth: Login und Benutzerverwaltung implementiert (rollenbasiert, persistente Sitzungen).", - "✨ UI: Benutzer-Menü oben rechts – DropDownButton mit Benutzername/Rolle; Einträge: ‘Profil’ und ‘Abmelden’.", - "🧩 Frontend: Syncfusion SplitButtons integriert (react-splitbuttons) und Vite-Konfiguration für Pre-Bundling ergänzt.", - "🐛 Fix: Import-Fehler ‘@syncfusion/ej2-react-splitbuttons’ – Anleitung in README hinzugefügt (optimizeDeps + Volume-Reset)." + "🎬 Client: Client kann jetzt Videos wiedergeben (Playback/UI surface) — Benutzerseitige Präsentation wurde ergänzt.", + "🧩 UI: Event-Modal ergänzt um Video-Auswahl und Wiedergabe-Optionen (Autoplay, Loop, Lautstärke).", + "📁 Medien-UI: FileManager erlaubt größere Uploads für Full-HD-Videos; Client-seitige Validierung begrenzt Videolänge auf 10 Minuten." ] }, { "version": "2025.1.0-alpha.9", - "date": "2025-10-14", + "date": "2025-10-19", "changes": [ - "✨ UI: Einheitlicher Lösch-Workflow für Termine – alle Typen (Einzeltermin, Einzelinstanz, ganze Serie) werden mit eigenen, benutzerfreundlichen Dialogen behandelt.", - "🔧 Frontend: Syncfusion-RecurrenceAlert und DeleteAlert werden abgefangen und durch eigene Dialoge ersetzt (inkl. finale Bestätigung für Serienlöschung).", - "✅ 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." + "🆕 Events: Darstellung für ‘WebUntis’ harmonisiert mit ‘Website’ (UI/representation).", + "🛠️ Einstellungen › Events: WebUntis verwendet jetzt die bestehende Supplement-Table-Einstellung (Settings UI updated)." ] }, { "version": "2025.1.0-alpha.8", - "date": "2025-10-11", + "date": "2025-10-18", "changes": [ - "🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports in main.tsx", - "🧹 Cleanup: Tailwind CSS komplett entfernt (Pakete, PostCSS, Stylelint, Konfigurationsdateien)", - "🧩 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)" + "✨ Einstellungen › Events › Präsentationen: Neue UI-Felder für Slide-Show Intervall, Page-Progress und Auto-Progress.", + "️ UI: Event-Modal lädt Präsentations-Einstellungen aus Global-Defaults bzw. Event-Daten (behaviour surfaced in UI)." ] }, { "version": "2025.1.0-alpha.7", - "date": "2025-09-21", + "date": "2025-10-16", "changes": [ - "🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout", - "✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler 'Ferien im Blick'", - "📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe", - "📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 2–4)", - "🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert" + "✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit.", + "🗓️ Einstellungen › Events: WebUntis/Vertretungsplan in Events-Tab (enable/preview in UI).", + "📅 UI: Akademische Periode kann in der Einstellungen-Seite direkt gesetzt werden." ] }, { "version": "2025.1.0-alpha.6", - "date": "2025-09-20", + "date": "2025-10-15", "changes": [ - "🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester", - "🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden", - "🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen" + "✨ UI: Benutzer-Menü (top-right) mit Name/Rolle und Einträgen 'Profil' und 'Abmelden'." ] }, { "version": "2025.1.0-alpha.5", - "date": "2025-09-14", + "date": "2025-10-14", "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", - "date": "2025-09-01", + "date": "2025-10-11", "changes": [ - "Grundstruktur für Deployment getestet und optimiert.", - "FIX: Programmfehler beim Umschalten der Ansicht auf der Medien-Seite behoben." + "🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports (UI theme update).", + "🧩 UI: Gruppenverwaltung ('infoscreen_groups') auf Syncfusion-Komponenten umgestellt.", + "🔔 UI: Vereinheitlichte Notifications / Toast-Texte für konsistente UX." ] }, { "version": "2025.1.0-alpha.3", - "date": "2025-08-30", + "date": "2025-09-21", "changes": [ - "NEU: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.", - "NEU: Logout-Funktionalität implementiert.", - "FIX: Breite der Sidebar im eingeklappten Zustand korrigiert." + "🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompakte Layout-Verbesserung.", + "✅ Anzeige: Abzeichen für vorhandenen Ferienplan + 'Ferien im Blick' Zähler (UI indicator).", + "📤 UI: Ferien-Upload (TXT/CSV) Benutzer-Workflow ergänzt." ] }, { "version": "2025.1.0-alpha.2", - "date": "2025-08-29", + "date": "2025-09-01", "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", - "date": "2025-08-28", + "date": "2025-08-30", "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." ] } ] diff --git a/dashboard/src/components/CustomEventModal.tsx b/dashboard/src/components/CustomEventModal.tsx index 96dd2d1..0219cc1 100644 --- a/dashboard/src/components/CustomEventModal.tsx +++ b/dashboard/src/components/CustomEventModal.tsx @@ -19,11 +19,15 @@ type CustomEventData = { weekdays: number[]; repeatUntil: Date | null; skipHolidays: boolean; - media?: { id: string; path: string; name: string } | null; // <--- ergänzt - slideshowInterval?: number; // <--- ergänzt - pageProgress?: boolean; // NEU - autoProgress?: boolean; // NEU - websiteUrl?: string; // <--- ergänzt + media?: { id: string; path: string; name: string } | null; + slideshowInterval?: number; + pageProgress?: boolean; + autoProgress?: boolean; + websiteUrl?: string; + // Video-specific fields + autoplay?: boolean; + loop?: boolean; + volume?: number; }; // Typ für initialData erweitern, damit Id unterstützt wird @@ -112,6 +116,12 @@ const CustomEventModal: React.FC = ({ initialData.autoProgress ?? true ); const [websiteUrl, setWebsiteUrl] = React.useState(initialData.websiteUrl ?? ''); + + // Video-specific state + const [autoplay, setAutoplay] = React.useState(initialData.autoplay ?? true); + const [loop, setLoop] = React.useState(initialData.loop ?? false); + const [volume, setVolume] = React.useState(initialData.volume ?? 0.8); + const [mediaModalOpen, setMediaModalOpen] = React.useState(false); React.useEffect(() => { @@ -141,7 +151,13 @@ const CustomEventModal: React.FC = ({ // --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen --- setMedia(initialData.media ?? null); setSlideshowInterval(initialData.slideshowInterval ?? 10); + setPageProgress(initialData.pageProgress ?? true); + setAutoProgress(initialData.autoProgress ?? true); setWebsiteUrl(initialData.websiteUrl ?? ''); + // Video fields + setAutoplay(initialData.autoplay ?? true); + setLoop(initialData.loop ?? false); + setVolume(initialData.volume ?? 0.8); } }, [open, initialData]); @@ -192,9 +208,15 @@ const CustomEventModal: React.FC = ({ if (type === 'website') { 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) - setErrors({}); + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName; @@ -259,7 +281,7 @@ const CustomEventModal: React.FC = ({ }; if (type === 'presentation') { - payload.event_media_id = media?.id; + payload.event_media_id = media?.id ? Number(media.id) : undefined; payload.slideshow_interval = slideshowInterval; payload.page_progress = pageProgress; payload.auto_progress = autoProgress; @@ -269,6 +291,13 @@ const CustomEventModal: React.FC = ({ 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 { let res; if (editMode && initialData && typeof initialData.Id === 'string') { @@ -601,6 +630,50 @@ const CustomEventModal: React.FC = ({ /> )} + {type === 'video' && ( +
+
+ +
+
+ Ausgewähltes Video:{' '} + {media ? ( + media.path + ) : ( + Kein Video ausgewählt + )} +
+
+ setAutoplay(e.checked || false)} + /> +
+
+ setLoop(e.checked || false)} + /> +
+
+ setVolume(Math.max(0, Math.min(1, Number(e.value))))} + /> +
+
+ )} diff --git a/dashboard/src/media.tsx b/dashboard/src/media.tsx index 2f71d9b..585b030 100644 --- a/dashboard/src/media.tsx +++ b/dashboard/src/media.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useState, useRef, useMemo } from 'react'; import CustomMediaInfoPanel from './components/CustomMediaInfoPanel'; import { @@ -96,6 +97,89 @@ const Media: React.FC = () => { uploadUrl: hostUrl + 'upload', 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((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={{ items: [ 'NewFolder', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6485637..d8e8b61 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -13,6 +13,8 @@ services: volumes: - ./nginx.conf:/etc/nginx/nginx.conf: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: - server - dashboard diff --git a/docker-compose.yml b/docker-compose.yml index 0f84030..615e91b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ services: volumes: - ./nginx.conf:/etc/nginx/nginx.conf: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: - server - dashboard diff --git a/nginx.conf b/nginx.conf index 4fa6785..0a6d536 100644 --- a/nginx.conf +++ b/nginx.conf @@ -9,6 +9,11 @@ http { server { listen 80; 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 location /api/ { @@ -17,6 +22,29 @@ http { location /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 location / { proxy_pass http://dashboard; diff --git a/scheduler/db_utils.py b/scheduler/db_utils.py index 017db81..8a58b04 100644 --- a/scheduler/db_utils.py +++ b/scheduler/db_utils.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker, joinedload from sqlalchemy import create_engine, or_, and_, text from models.models import Event, EventMedia, EventException from dateutil.rrule import rrulestr +from urllib.request import Request, urlopen from datetime import timezone # 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}") _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 diff --git a/server/routes/eventmedia.py b/server/routes/eventmedia.py index 9eb1b40..0c79c87 100644 --- a/server/routes/eventmedia.py +++ b/server/routes/eventmedia.py @@ -1,5 +1,5 @@ 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.database import Session 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 import hashlib import os +import re eventmedia_bp = Blueprint('eventmedia', __name__, url_prefix='/api/eventmedia') @@ -304,3 +305,63 @@ def get_media_by_id(media_id): } session.close() return jsonify(result) + + +# --- Video Streaming with Range Request Support --- +@eventmedia_bp.route('/stream//', 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 diff --git a/server/routes/events.py b/server/routes/events.py index f58a21d..4331449 100644 --- a/server/routes/events.py +++ b/server/routes/events.py @@ -394,6 +394,19 @@ def create_event(): session.commit() 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 = data.get("created_by") @@ -419,6 +432,9 @@ def create_event(): is_active=True, event_media_id=event_media_id, slideshow_interval=slideshow_interval, + autoplay=autoplay, + loop=loop, + volume=volume, created_by=created_by, # Recurrence 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_media_id = data.get("event_media_id", event.event_media_id) 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) # Track previous values to decide on exception regeneration prev_rule = event.recurrence_rule diff --git a/server/routes/files.py b/server/routes/files.py index 3012e2e..f9515b6 100644 --- a/server/routes/files.py +++ b/server/routes/files.py @@ -3,6 +3,8 @@ from server.database import Session from models.models import EventMedia import os +from flask import Response, abort, session as flask_session + # Blueprint for direct file downloads by media ID 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): return jsonify({"error": "File not found"}), 404 return send_from_directory(os.path.dirname(abs_path), os.path.basename(abs_path), as_attachment=True) + + +@files_bp.route('/stream/') +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 diff --git a/server/wsgi.py b/server/wsgi.py index 6eb3dfd..e35f0c9 100644 --- a/server/wsgi.py +++ b/server/wsgi.py @@ -19,6 +19,10 @@ sys.path.append('/workspace') 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 # 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')