From c89e31b9988551e544108727513fdef092d08a4c Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Mon, 8 Dec 2025 09:08:24 +0300 Subject: [PATCH] Add geo stats collection and improve admin dashboard UI - Add geo IP lookup for listener locations (ip-api.com) - Add geo stats table with country flags to admin dashboard - Fix listener stats table alignment with proper centering - Fix Now Playing display to update without requiring audio playback - Add caching for geo lookups to reduce API calls --- TODO.org | 14 ++--- listener-stats.lisp | 79 +++++++++++++++++++++++++++ parenscript/admin.lisp | 7 +-- static/asteroid.css | 40 ++++++++++++++ static/asteroid.lass | 34 +++++++++++- template/admin.ctml | 121 +++++++++++++++++++++++++++++++---------- 6 files changed, 252 insertions(+), 43 deletions(-) diff --git a/TODO.org b/TODO.org index 83a37c7..970a379 100644 --- a/TODO.org +++ b/TODO.org @@ -25,18 +25,18 @@ 1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio 2) [X] icecast is also binding the external interface on b612, which it should not be. HAproxy is there to mediate this flow. -3) [ ] We're still on the built in i-lambdalite database +3) [X] We're still on the built in i-lambdalite database 4) [X] The templates still advertise the default administrator password, which is no bueno. 5) [ ] We need to work out the TLS situation with letsencrypt, and integrate it into HAproxy. 6) [ ] The administrative interface should be beefed up. - 6.1) [ ] Deactivate users - 6.2) [ ] Change user access permissions + 6.1) [X] Deactivate users + 6.2) [X] Change user access permissions 6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c -7) [ ] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused. +7) [X] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused. 8) [ ] User profile pages should probably be fleshed out. 9) [ ] the stream management features aren't there for Admins or DJs. 10) [ ] The "Scan Library" feature is not working in the main branch @@ -48,11 +48,11 @@ - [ ] strip hard coded configurations out of the system - [ ] add configuration template file to the project -** [ ] Database [0/1] +** [X] Database [0/1] - [-] PostgresQL [1/3] - [X] Add a postgresql docker image to our docker-compose file. - - [ ] Configure radiance for postres. - - [ ] Migrate all schema to new database. + - [X] Configure radiance for postres. + - [X] Migrate all schema to new database. ** [X] Page Flow [2/2] ✅ COMPLETE diff --git a/listener-stats.lisp b/listener-stats.lisp index cf5b3e0..e86039c 100644 --- a/listener-stats.lisp +++ b/listener-stats.lisp @@ -47,6 +47,13 @@ (defvar *active-listeners* (make-hash-table :test 'equal) "Hash table tracking active listeners by IP hash") +;;; Geo lookup cache (IP hash -> country code) +(defvar *geo-cache* (make-hash-table :test 'equal) + "Cache of IP hash to country code mappings") + +(defvar *geo-cache-ttl* 3600 + "Seconds to cache geo lookups (1 hour)") + ;;; Utility Functions (defun hash-ip-address (ip-address) @@ -128,6 +135,30 @@ (when xml-string (extract-xml-sources xml-string))) +(defun fetch-icecast-listclients (mount) + "Fetch listener list for a specific mount from Icecast admin" + (handler-case + (let* ((url (format nil "http://localhost:8000/admin/listclients?mount=~a" mount)) + (response (drakma:http-request url + :want-stream nil + :connection-timeout 5 + :basic-authorization (list *icecast-admin-user* + *icecast-admin-pass*)))) + (if (stringp response) + response + (babel:octets-to-string response :encoding :utf-8))) + (error (e) + (log:debug "Failed to fetch listclients for ~a: ~a" mount e) + nil))) + +(defun extract-listener-ips (xml-string) + "Extract listener IPs from Icecast listclients XML" + (let ((ips nil) + (pattern "([^<]+)")) + (cl-ppcre:do-register-groups (ip) (pattern xml-string) + (push ip ips)) + (nreverse ips))) + ;;; Database Operations (defun store-listener-snapshot (mount listener-count) @@ -178,6 +209,21 @@ (error (e) (log:error "Session cleanup failed: ~a" e)))) +(defun update-geo-stats (country-code listener-count) + "Update geo stats for today" + (when country-code + (handler-case + (with-db + (postmodern:execute + (format nil "INSERT INTO listener_geo_stats (date, country_code, listener_count, listen_minutes) + VALUES (CURRENT_DATE, '~a', ~a, 1) + ON CONFLICT (date, country_code) + DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a, + listen_minutes = listener_geo_stats.listen_minutes + 1" + country-code listener-count listener-count))) + (error (e) + (log:error "Failed to update geo stats: ~a" e))))) + ;;; Statistics Aggregation ;;; Note: Complex aggregation queries use raw SQL via postmodern:execute @@ -299,6 +345,36 @@ ;;; Polling Service +(defun get-cached-geo (ip) + "Get cached geo data for IP, or lookup and cache" + (let* ((ip-hash (hash-ip-address ip)) + (cached (gethash ip-hash *geo-cache*))) + (if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*)) + (getf cached :country) + ;; Lookup and cache + (let ((geo (lookup-geoip ip))) + (when geo + (let ((country (getf geo :country-code))) + (setf (gethash ip-hash *geo-cache*) + (list :country country :time (get-universal-time))) + country)))))) + +(defun collect-geo-stats-for-mount (mount) + "Collect geo stats for all listeners on a mount" + (let ((listclients-xml (fetch-icecast-listclients mount))) + (when listclients-xml + (let ((ips (extract-listener-ips listclients-xml)) + (country-counts (make-hash-table :test 'equal))) + ;; Group by country + (dolist (ip ips) + (let ((country (get-cached-geo ip))) + (when country + (incf (gethash country country-counts 0))))) + ;; Store each country's count + (maphash (lambda (country count) + (update-geo-stats country count)) + country-counts))))) + (defun poll-and-store-stats () "Single poll iteration: fetch stats and store" (let ((stats (fetch-icecast-stats))) @@ -309,6 +385,9 @@ (listeners (getf source :listeners))) (when mount (store-listener-snapshot mount listeners) + ;; Collect geo stats if there are listeners + (when (and listeners (> listeners 0)) + (collect-geo-stats-for-mount mount)) (log:debug "Stored snapshot: ~a = ~a listeners" mount listeners)))))))) (defun stats-polling-loop () diff --git a/parenscript/admin.lisp b/parenscript/admin.lisp index 228f16e..9cc59b4 100644 --- a/parenscript/admin.lisp +++ b/parenscript/admin.lisp @@ -367,16 +367,11 @@ ;; Live stream info update (defun update-live-stream-info () - ;; Don't update if stream is paused - (let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio")))) - (when (and live-audio (ps:@ live-audio paused)) - (return))) - (ps:chain (fetch "/api/asteroid/partial/now-playing-inline") (then (lambda (response) (let ((content-type (ps:chain response headers (get "content-type")))) - (unless (ps:chain content-type (includes "text/plain")) + (unless (and content-type (ps:chain content-type (includes "text/plain"))) (ps:chain console (error "Unexpected content type:" content-type)) (return)) (ps:chain response (text))))) diff --git a/static/asteroid.css b/static/asteroid.css index f1b4190..ad467f4 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1230,6 +1230,46 @@ body .stat-card .stat-detail{ margin-top: 0.25rem; } +body .stat-card .listener-stats-table{ + width: 100%; + border-collapse: collapse; + background: #111; + border: 1px solid #333; + table-layout: fixed; +} + +body .stat-card .listener-stats-table th{ + background: #1a1a1a; + color: #00ff00; + padding: 12px 20px; + text-align: center; + border: 1px solid #333; + font-size: 0.9rem; + width: 25%; +} + +body .stat-card .listener-stats-table td{ + padding: 12px 20px; + text-align: center; + border: 1px solid #333; + vertical-align: middle; + width: 25%; +} + +body .stat-card .listener-stats-table .stat-number{ + font-size: 1.75rem; + font-weight: bold; + color: #00ffff; + display: block; + text-align: center; +} + +body .stat-card .listener-stats-table .stat-peak-row{ + font-size: 0.8rem; + color: #888; + background: #0a0a0a; +} + body.persistent-player-container{ margin: 0; padding: 10px;; diff --git a/static/asteroid.lass b/static/asteroid.lass index 582943c..50b0b97 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -982,7 +982,39 @@ (.stat-detail :color "#888" :font-size 0.75rem - :margin-top 0.25rem)) + :margin-top 0.25rem) + + (.listener-stats-table + :width 100% + :border-collapse collapse + :background "#111" + :border "1px solid #333" + :table-layout fixed + + (th :background "#1a1a1a" + :color "#00ff00" + :padding "12px 20px" + :text-align center + :border "1px solid #333" + :font-size "0.9rem" + :width "25%") + + (td :padding "12px 20px" + :text-align "center" + :border "1px solid #333" + :vertical-align "middle" + :width "25%") + + (.stat-number :font-size 1.75rem + :font-weight bold + :color "#00ffff" + :display block + :text-align "center") + + (.stat-peak-row + :font-size 0.8rem + :color "#888" + :background "#0a0a0a"))) ;; Center alignment for player page ;; (body.player-page diff --git a/template/admin.ctml b/template/admin.ctml index 345e40f..59bc75f 100644 --- a/template/admin.ctml +++ b/template/admin.ctml @@ -44,37 +44,58 @@
-

📊 Listener Statistics

-
-
-

🎵 asteroid.mp3

-

0

-

Current Listeners

-

Peak: 0

-
-
-

🎧 asteroid.aac

-

0

-

Current Listeners

-

Peak: 0

-
-
-

📱 asteroid-low.mp3

-

0

-

Current Listeners

-

Peak: 0

-
-
-

📈 Total Listeners

-

0

-

All Streams

-

Updated: --

-
-
-
- +

📊 Current Listeners

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
🎵 MP3🎧 AAC📱 Low📈 Total
0000
Peak: 0Peak: 0Peak: 0Updated: --
+
+
+ + +

🌍 Listener Locations (Last 7 Days)

+
+ + + + + + + + + + + +
CountryListenersMinutes
Loading...
+
@@ -273,12 +294,54 @@ }); } + // Country code to flag emoji + function countryToFlag(countryCode) { + if (!countryCode || countryCode.length !== 2) return '🌍'; + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); + } + + // Fetch and display geo stats + function refreshGeoStats() { + fetch('/api/asteroid/stats/geo?days=7') + .then(response => response.json()) + .then(result => { + const data = result.data || result; + const tbody = document.getElementById('geo-stats-body'); + + if (data.status === 'success' && data.geo && data.geo.length > 0) { + tbody.innerHTML = data.geo.map(item => { + const country = item.country_code || item[0]; + const listeners = item.total_listeners || item[1] || 0; + const minutes = item.total_minutes || item[2] || 0; + return ` + ${countryToFlag(country)} ${country} + ${listeners} + ${minutes} + `; + }).join(''); + } else { + tbody.innerHTML = 'No geo data yet'; + } + }) + .catch(error => { + console.error('Error fetching geo stats:', error); + document.getElementById('geo-stats-body').innerHTML = + 'Error loading geo data'; + }); + } + // Auto-refresh stats every 30 seconds setInterval(refreshListenerStats, 30000); + setInterval(refreshGeoStats, 60000); // Initial load document.addEventListener('DOMContentLoaded', function() { refreshListenerStats(); + refreshGeoStats(); }); // Admin password reset handler