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
This commit is contained in:
Glenn Thompson 2025-12-08 09:08:24 +03:00 committed by Brian O'Reilly
parent 4be3b83da1
commit c89e31b998
6 changed files with 252 additions and 43 deletions

View File

@ -25,18 +25,18 @@
1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio 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 2) [X] icecast is also binding the external interface on b612, which it
should not be. HAproxy is there to mediate this flow. 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, 4) [X] The templates still advertise the default administrator password,
which is no bueno. which is no bueno.
5) [ ] We need to work out the TLS situation with letsencrypt, and 5) [ ] We need to work out the TLS situation with letsencrypt, and
integrate it into HAproxy. integrate it into HAproxy.
6) [ ] The administrative interface should be beefed up. 6) [ ] The administrative interface should be beefed up.
6.1) [ ] Deactivate users 6.1) [X] Deactivate users
6.2) [ ] Change user access permissions 6.2) [X] Change user access permissions
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c 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. 8) [ ] User profile pages should probably be fleshed out.
9) [ ] the stream management features aren't there for Admins or DJs. 9) [ ] the stream management features aren't there for Admins or DJs.
10) [ ] The "Scan Library" feature is not working in the main branch 10) [ ] The "Scan Library" feature is not working in the main branch
@ -48,11 +48,11 @@
- [ ] strip hard coded configurations out of the system - [ ] strip hard coded configurations out of the system
- [ ] add configuration template file to the project - [ ] add configuration template file to the project
** [ ] Database [0/1] ** [X] Database [0/1]
- [-] PostgresQL [1/3] - [-] PostgresQL [1/3]
- [X] Add a postgresql docker image to our docker-compose file. - [X] Add a postgresql docker image to our docker-compose file.
- [ ] Configure radiance for postres. - [X] Configure radiance for postres.
- [ ] Migrate all schema to new database. - [X] Migrate all schema to new database.
** [X] Page Flow [2/2] ✅ COMPLETE ** [X] Page Flow [2/2] ✅ COMPLETE

View File

@ -47,6 +47,13 @@
(defvar *active-listeners* (make-hash-table :test 'equal) (defvar *active-listeners* (make-hash-table :test 'equal)
"Hash table tracking active listeners by IP hash") "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 ;;; Utility Functions
(defun hash-ip-address (ip-address) (defun hash-ip-address (ip-address)
@ -128,6 +135,30 @@
(when xml-string (when xml-string
(extract-xml-sources 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 "<IP>([^<]+)</IP>"))
(cl-ppcre:do-register-groups (ip) (pattern xml-string)
(push ip ips))
(nreverse ips)))
;;; Database Operations ;;; Database Operations
(defun store-listener-snapshot (mount listener-count) (defun store-listener-snapshot (mount listener-count)
@ -178,6 +209,21 @@
(error (e) (error (e)
(log:error "Session cleanup failed: ~a" 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 ;;; Statistics Aggregation
;;; Note: Complex aggregation queries use raw SQL via postmodern:execute ;;; Note: Complex aggregation queries use raw SQL via postmodern:execute
@ -299,6 +345,36 @@
;;; Polling Service ;;; 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 () (defun poll-and-store-stats ()
"Single poll iteration: fetch stats and store" "Single poll iteration: fetch stats and store"
(let ((stats (fetch-icecast-stats))) (let ((stats (fetch-icecast-stats)))
@ -309,6 +385,9 @@
(listeners (getf source :listeners))) (listeners (getf source :listeners)))
(when mount (when mount
(store-listener-snapshot mount listeners) (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)))))))) (log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))))))
(defun stats-polling-loop () (defun stats-polling-loop ()

View File

@ -367,16 +367,11 @@
;; Live stream info update ;; Live stream info update
(defun update-live-stream-info () (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 (ps:chain
(fetch "/api/asteroid/partial/now-playing-inline") (fetch "/api/asteroid/partial/now-playing-inline")
(then (lambda (response) (then (lambda (response)
(let ((content-type (ps:chain response headers (get "content-type")))) (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)) (ps:chain console (error "Unexpected content type:" content-type))
(return)) (return))
(ps:chain response (text))))) (ps:chain response (text)))))

View File

@ -1230,6 +1230,46 @@ body .stat-card .stat-detail{
margin-top: 0.25rem; 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{ body.persistent-player-container{
margin: 0; margin: 0;
padding: 10px;; padding: 10px;;

View File

@ -982,7 +982,39 @@
(.stat-detail :color "#888" (.stat-detail :color "#888"
:font-size 0.75rem :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 ;; Center alignment for player page
;; (body.player-page ;; (body.player-page

View File

@ -44,37 +44,58 @@
<!-- Listener Statistics --> <!-- Listener Statistics -->
<div class="admin-section"> <div class="admin-section">
<h2>📊 Listener Statistics</h2> <h2>📊 Current Listeners</h2>
<div class="admin-grid" id="listener-stats-grid"> <table class="listener-stats-table" style="table-layout: fixed; width: 100%;">
<div class="status-card"> <colgroup>
<h3>🎵 asteroid.mp3</h3> <col style="width: 25%;">
<p class="stat-number" id="listeners-mp3">0</p> <col style="width: 25%;">
<p class="stat-label">Current Listeners</p> <col style="width: 25%;">
<p class="stat-detail">Peak: <span id="peak-mp3">0</span></p> <col style="width: 25%;">
</div> </colgroup>
<div class="status-card"> <thead>
<h3>🎧 asteroid.aac</h3> <tr>
<p class="stat-number" id="listeners-aac">0</p> <th>🎵 MP3</th>
<p class="stat-label">Current Listeners</p> <th>🎧 AAC</th>
<p class="stat-detail">Peak: <span id="peak-aac">0</span></p> <th>📱 Low</th>
</div> <th>📈 Total</th>
<div class="status-card"> </tr>
<h3>📱 asteroid-low.mp3</h3> </thead>
<p class="stat-number" id="listeners-low">0</p> <tbody>
<p class="stat-label">Current Listeners</p> <tr>
<p class="stat-detail">Peak: <span id="peak-low">0</span></p> <td style="text-align: center;"><span class="stat-number" id="listeners-mp3">0</span></td>
</div> <td style="text-align: center;"><span class="stat-number" id="listeners-aac">0</span></td>
<div class="status-card"> <td style="text-align: center;"><span class="stat-number" id="listeners-low">0</span></td>
<h3>📈 Total Listeners</h3> <td style="text-align: center;"><span class="stat-number" id="listeners-total">0</span></td>
<p class="stat-number" id="listeners-total">0</p> </tr>
<p class="stat-label">All Streams</p> <tr class="stat-peak-row">
<p class="stat-detail">Updated: <span id="stats-updated">--</span></p> <td style="text-align: center;">Peak: <span id="peak-mp3">0</span></td>
</div> <td style="text-align: center;">Peak: <span id="peak-aac">0</span></td>
</div> <td style="text-align: center;">Peak: <span id="peak-low">0</span></td>
<div class="admin-controls" style="margin-top: 15px;"> <td style="text-align: center;">Updated: <span id="stats-updated">--</span></td>
<button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">🔄 Refresh Stats</button> </tr>
</tbody>
</table>
<div class="admin-controls" style="margin-top: 10px;">
<button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">🔄 Refresh</button>
<span id="stats-status" style="margin-left: 15px;"></span> <span id="stats-status" style="margin-left: 15px;"></span>
</div> </div>
<!-- Geo Stats -->
<h3 style="margin-top: 20px;">🌍 Listener Locations (Last 7 Days)</h3>
<div id="geo-stats-container">
<table class="listener-stats-table" id="geo-stats-table">
<thead>
<tr>
<th>Country</th>
<th>Listeners</th>
<th>Minutes</th>
</tr>
</thead>
<tbody id="geo-stats-body">
<tr><td colspan="3" style="color: #888;">Loading...</td></tr>
</tbody>
</table>
</div>
</div> </div>
<!-- Music Library Management --> <!-- Music Library Management -->
@ -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 `<tr>
<td>${countryToFlag(country)} ${country}</td>
<td>${listeners}</td>
<td>${minutes}</td>
</tr>`;
}).join('');
} else {
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
}
})
.catch(error => {
console.error('Error fetching geo stats:', error);
document.getElementById('geo-stats-body').innerHTML =
'<tr><td colspan="3" style="color: #ff6666;">Error loading geo data</td></tr>';
});
}
// Auto-refresh stats every 30 seconds // Auto-refresh stats every 30 seconds
setInterval(refreshListenerStats, 30000); setInterval(refreshListenerStats, 30000);
setInterval(refreshGeoStats, 60000);
// Initial load // Initial load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
refreshListenerStats(); refreshListenerStats();
refreshGeoStats();
}); });
// Admin password reset handler // Admin password reset handler