Skip to content

Commit d50bf1d

Browse files
authored
Merge pull request #59 from TimUx/copilot/add-statistics-page
Add statistics page for tracking user interactions with Docker support
2 parents decc763 + 49865b1 commit d50bf1d

File tree

7 files changed

+839
-27
lines changed

7 files changed

+839
-27
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ ENV/
1616

1717
# Local environment
1818
.env
19+
20+
# Statistics data directory (mounted as volume in Docker)
21+
data/

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ COPY requirements.txt .
66
RUN pip install --no-cache-dir -r requirements.txt
77

88
# ship all static pages plus the entrypoint and backend
9-
COPY index.html donation.html requests.html styles.css config.template.js docker-entrypoint.sh app.py .
9+
COPY index.html donation.html requests.html statistics.html styles.css config.template.js docker-entrypoint.sh app.py .
1010

1111
ENV PORT=8000
1212
EXPOSE 8000

app.py

Lines changed: 152 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
SHOW_START_TIME = os.getenv("FPP_SHOW_START_TIME", "16:30")
3535
SHOW_END_TIME = os.getenv("FPP_SHOW_END_TIME", "22:00")
3636
SCHEDULED_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"]
3738
POLL_INTERVAL_SECONDS = max(5, int(os.getenv("FPP_POLL_INTERVAL_MS", "15000")) // 1000)
3839
REQUEST_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

259320
def 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")
796862
def 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})\nPosition 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+
10161151
def boot_threads():
10171152
threading.Thread(target=status_worker, daemon=True).start()
10181153
threading.Thread(target=scheduler_worker, daemon=True).start()

docker-compose.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ services:
66
env_file:
77
- .env
88
restart: unless-stopped
9-
# DNS configuration for proper name resolution (e.g., ntfy.sh)
10-
dns:
11-
- 8.8.8.8
12-
- 8.8.4.4
9+
volumes:
10+
- ./data:/app/data
1311
network_mode: bridge

index.html

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ <h3>Warteschlange</h3>
6060
</div>
6161

6262
<footer class="social-footer hidden" id="social-footer">
63+
<div class="social-footer-stats">
64+
<a href="/statistics" class="link-like">📊 Statistiken ansehen</a>
65+
</div>
6366
<h3 class="social-footer-title">Unsere Kanäle:</h3>
6467
<div class="social-icons" id="social-icons"></div>
6568
</footer>
@@ -333,7 +336,9 @@ <h3 class="social-footer-title">Unsere Kanäle:</h3>
333336
} catch (err) {
334337
console.error('Statusfehler', err);
335338
markStatus('error', 'Status konnte nicht geladen werden.');
336-
setButtonsDisabled(true, 'Bitte Verbindung zum Server prüfen.');
339+
setStatusDetails('', '', '');
340+
note.textContent = 'Bitte Verbindung zum Server prüfen.';
341+
setButtonsDisabled(true, '');
337342
}
338343
}
339344

@@ -472,9 +477,8 @@ <h3 class="social-footer-title">Unsere Kanäle:</h3>
472477
}
473478
});
474479

475-
if (hasAny) {
476-
socialFooter.classList.remove('hidden');
477-
}
480+
// Always show footer since statistics link is always present
481+
socialFooter.classList.remove('hidden');
478482
}
479483

480484
// Check for pending toast from song request

0 commit comments

Comments
 (0)