3434SHOW_START_TIME = os .getenv ("FPP_SHOW_START_TIME" , "16:30" )
3535SHOW_END_TIME = os .getenv ("FPP_SHOW_END_TIME" , "22:00" )
3636SCHEDULED_SHOWS_ENABLED = os .getenv ("SCHEDULED_SHOWS_ENABLED" , "true" ).lower () in ["true" , "1" , "yes" , "on" ]
37+ PREVIEW_MODE = os .getenv ("PREVIEW_MODE" , "false" ).lower () in ["true" , "1" , "yes" , "on" ]
3738POLL_INTERVAL_SECONDS = max (5 , int (os .getenv ("FPP_POLL_INTERVAL_MS" , "15000" )) // 1000 )
3839REQUEST_TIMEOUT = 8
3940
@@ -124,7 +125,7 @@ def send_notification(title: str, message: str, action_type: str = "info", extra
124125 in one channel does not affect others.
125126
126127 Args:
127- title: Short notification title (e.g., "🎄 Show gestartet")
128+ title: Short notification title (e.g., "Show gestartet")
128129 message: Full notification message body
129130 action_type: Type of action for categorization. Common values:
130131 - "show_start": Show was started via button
@@ -136,7 +137,7 @@ def send_notification(title: str, message: str, action_type: str = "info", extra
136137
137138 Example:
138139 >>> send_notification(
139- ... title="🎄 Hauptshow gestartet",
140+ ... title="Hauptshow gestartet",
140141 ... message="Ein Besucher hat 'show 1' gestartet.",
141142 ... action_type="show_start",
142143 ... extra_data={"playlist": "show 1"}
@@ -150,6 +151,11 @@ def send_notification(title: str, message: str, action_type: str = "info", extra
150151 if not NOTIFY_ENABLED :
151152 return
152153
154+ # Skip notifications in preview mode
155+ if PREVIEW_MODE :
156+ logger .info (f"Preview mode: Skipping notification - { title } " )
157+ return
158+
153159 timestamp = dt_datetime .now ().isoformat ()
154160 payload = {
155161 "title" : title ,
@@ -176,27 +182,24 @@ def send_notification(title: str, message: str, action_type: str = "info", extra
176182 # Send via ntfy.sh
177183 if NOTIFY_NTFY_ENABLED and NOTIFY_NTFY_TOPIC :
178184 try :
179- # ntfy.sh API: POST to base URL with JSON payload including topic
180- # This format properly displays in the ntfy.sh mobile app
181- url = NOTIFY_NTFY_URL
185+ # ntfy.sh API: POST to topic URL with message as text body
186+ # Headers are used for title, priority, and tags
187+ url = f" { NOTIFY_NTFY_URL } / { NOTIFY_NTFY_TOPIC } "
182188
183- json_payload = {
184- "topic" : NOTIFY_NTFY_TOPIC ,
185- "title" : title ,
186- "message" : message ,
187- "priority" : "default" ,
188- "tags" : [action_type ],
189+ headers = {
190+ "Title" : title ,
191+ "Priority" : "default" ,
192+ "Tags" : action_type
189193 }
190194
191- headers = {}
192195 if NOTIFY_NTFY_TOKEN :
193196 headers ["Authorization" ] = f"Bearer { NOTIFY_NTFY_TOKEN } "
194197
195- # Send as JSON with proper UTF-8 encoding
198+ # Send message as plain text body (not JSON)
196199 response = requests .post (
197200 url ,
198- json = json_payload ,
199- headers = headers if headers else None ,
201+ data = message ,
202+ headers = headers ,
200203 timeout = 5
201204 )
202205
@@ -255,6 +258,64 @@ def send_notification(title: str, message: str, action_type: str = "info", extra
255258 "background_active" : False ,
256259}
257260
261+ # Statistics storage
262+ # STATISTICS_FILE uses a data directory that can be mounted as a volume in Docker
263+ # Falls back to app directory if data directory doesn't exist (for development)
264+ STATISTICS_DIR = os .path .join (os .path .dirname (__file__ ), "data" )
265+ if not os .path .exists (STATISTICS_DIR ):
266+ os .makedirs (STATISTICS_DIR , exist_ok = True )
267+ STATISTICS_FILE = os .path .join (STATISTICS_DIR , "statistics.json" )
268+ statistics_lock = threading .RLock ()
269+
270+ def load_statistics () -> Dict [str , Any ]:
271+ """Load statistics from persistent storage."""
272+ if not os .path .exists (STATISTICS_FILE ):
273+ return {"show_starts" : [], "song_requests" : []}
274+ try :
275+ with open (STATISTICS_FILE , "r" , encoding = "utf-8" ) as f :
276+ return json .load (f )
277+ except Exception as e :
278+ logger .error (f"Failed to load statistics: { e } " )
279+ return {"show_starts" : [], "song_requests" : []}
280+
281+ def save_statistics (stats : Dict [str , Any ]) -> None :
282+ """Save statistics to persistent storage with atomic write.
283+
284+ Note: Writes immediately on each event. For typical home automation usage with low
285+ event frequency (few show starts/song requests per hour), this is acceptable.
286+ For high-traffic scenarios, consider implementing a write buffer.
287+ """
288+ try :
289+ # Atomic write: write to temp file first, then rename
290+ temp_file = STATISTICS_FILE + ".tmp"
291+ with open (temp_file , "w" , encoding = "utf-8" ) as f :
292+ json .dump (stats , f , ensure_ascii = False , indent = 2 )
293+ os .replace (temp_file , STATISTICS_FILE )
294+ except Exception as e :
295+ logger .error (f"Failed to save statistics: { e } " )
296+
297+ def log_show_start (playlist : str , playlist_type : str ) -> None :
298+ """Log a show start event."""
299+ with statistics_lock :
300+ stats = load_statistics ()
301+ stats ["show_starts" ].append ({
302+ "timestamp" : dt_datetime .now ().isoformat (),
303+ "playlist" : playlist ,
304+ "playlist_type" : playlist_type
305+ })
306+ save_statistics (stats )
307+
308+ def log_song_request (song_title : str , duration : Optional [int ]) -> None :
309+ """Log a song request event."""
310+ with statistics_lock :
311+ stats = load_statistics ()
312+ stats ["song_requests" ].append ({
313+ "timestamp" : dt_datetime .now ().isoformat (),
314+ "song_title" : song_title ,
315+ "duration" : duration
316+ })
317+ save_statistics (stats )
318+
258319
259320def normalize (name : Optional [str ]) -> str :
260321 if isinstance (name , str ):
@@ -792,6 +853,11 @@ def requests_page():
792853 return send_from_directory ("." , "requests.html" )
793854
794855
856+ @app .route ("/statistics" )
857+ def statistics_page ():
858+ return send_from_directory ("." , "statistics.html" )
859+
860+
795861@app .route ("/config.js" )
796862def config_js ():
797863 return send_from_directory ("." , "config.js" )
@@ -831,7 +897,10 @@ def api_show():
831897 return denied
832898 kind = payload .get ("type" , "playlist1" )
833899 playlist = PLAYLIST_2 if kind == "playlist2" else PLAYLIST_1
834- playlist_label = "🎄 Hauptshow" if kind == "playlist1" else "👶 Kids-Show"
900+ playlist_label = "Hauptshow" if kind == "playlist1" else "Kids-Show"
901+
902+ # Log show start to statistics
903+ log_show_start (playlist , kind )
835904
836905 # Send notification (before FPP operations, so it works in preview mode too)
837906 send_notification (
@@ -982,10 +1051,13 @@ def api_requests():
9821051 position = len (queue )
9831052 should_start = position == 1 and not state .get ("scheduled_show_active" , False )
9841053
1054+ # Log song request to statistics
1055+ log_song_request (title , duration )
1056+
9851057 # Send notification for song request (before FPP operations, so it works in preview mode too)
9861058 duration_str = format_duration (duration )
9871059 send_notification (
988- title = f"🎵 Neuer Liedwunsch" ,
1060+ title = f"Neuer Liedwunsch" ,
9891061 message = f"Ein Besucher wünscht sich: '{ title } ' (Dauer: { duration_str } )\n Position in Warteschlange: { position } " ,
9901062 action_type = "song_request" ,
9911063 extra_data = {
@@ -1013,6 +1085,69 @@ def health():
10131085 return {"status" : "ok" }
10141086
10151087
1088+ @app .route ("/api/statistics" )
1089+ def api_statistics ():
1090+ """Return aggregated statistics for user interactions."""
1091+ with statistics_lock :
1092+ stats = load_statistics ()
1093+
1094+ # Process show starts
1095+ show_starts = stats .get ("show_starts" , [])
1096+ show_stats = {}
1097+ show_timeline = []
1098+
1099+ for entry in show_starts :
1100+ playlist = entry .get ("playlist" , "Unknown" )
1101+ if playlist not in show_stats :
1102+ show_stats [playlist ] = 0
1103+ show_stats [playlist ] += 1
1104+ show_timeline .append ({
1105+ "timestamp" : entry .get ("timestamp" ),
1106+ "playlist" : playlist ,
1107+ "playlist_type" : entry .get ("playlist_type" , "unknown" )
1108+ })
1109+
1110+ # Process song requests
1111+ song_requests = stats .get ("song_requests" , [])
1112+ song_stats = {}
1113+ song_timeline = []
1114+
1115+ for entry in song_requests :
1116+ song = entry .get ("song_title" , "Unknown" )
1117+ if song not in song_stats :
1118+ song_stats [song ] = {"count" : 0 , "total_duration" : 0 }
1119+ song_stats [song ]["count" ] += 1
1120+ duration = entry .get ("duration" , 0 ) or 0
1121+ song_stats [song ]["total_duration" ] += duration
1122+ song_timeline .append ({
1123+ "timestamp" : entry .get ("timestamp" ),
1124+ "song_title" : song ,
1125+ "duration" : duration
1126+ })
1127+
1128+ # Get top 5 songs
1129+ top_songs = sorted (
1130+ [{"song" : k , "count" : v ["count" ], "total_duration" : v ["total_duration" ]}
1131+ for k , v in song_stats .items ()],
1132+ key = lambda x : x ["count" ],
1133+ reverse = True
1134+ )[:5 ]
1135+
1136+ return jsonify ({
1137+ "show_starts" : {
1138+ "total" : len (show_starts ),
1139+ "by_playlist" : show_stats ,
1140+ "timeline" : show_timeline
1141+ },
1142+ "song_requests" : {
1143+ "total" : len (song_requests ),
1144+ "by_song" : song_stats ,
1145+ "timeline" : song_timeline ,
1146+ "top_5" : top_songs
1147+ }
1148+ })
1149+
1150+
10161151def boot_threads ():
10171152 threading .Thread (target = status_worker , daemon = True ).start ()
10181153 threading .Thread (target = scheduler_worker , daemon = True ).start ()
0 commit comments