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
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

View File

@ -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 "<IP>([^<]+)</IP>"))
(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 ()

View File

@ -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)))))

View File

@ -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;;

View File

@ -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

View File

@ -44,37 +44,58 @@
<!-- Listener Statistics -->
<div class="admin-section">
<h2>📊 Listener Statistics</h2>
<div class="admin-grid" id="listener-stats-grid">
<div class="status-card">
<h3>🎵 asteroid.mp3</h3>
<p class="stat-number" id="listeners-mp3">0</p>
<p class="stat-label">Current Listeners</p>
<p class="stat-detail">Peak: <span id="peak-mp3">0</span></p>
</div>
<div class="status-card">
<h3>🎧 asteroid.aac</h3>
<p class="stat-number" id="listeners-aac">0</p>
<p class="stat-label">Current Listeners</p>
<p class="stat-detail">Peak: <span id="peak-aac">0</span></p>
</div>
<div class="status-card">
<h3>📱 asteroid-low.mp3</h3>
<p class="stat-number" id="listeners-low">0</p>
<p class="stat-label">Current Listeners</p>
<p class="stat-detail">Peak: <span id="peak-low">0</span></p>
</div>
<div class="status-card">
<h3>📈 Total Listeners</h3>
<p class="stat-number" id="listeners-total">0</p>
<p class="stat-label">All Streams</p>
<p class="stat-detail">Updated: <span id="stats-updated">--</span></p>
</div>
</div>
<div class="admin-controls" style="margin-top: 15px;">
<button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">🔄 Refresh Stats</button>
<h2>📊 Current Listeners</h2>
<table class="listener-stats-table" style="table-layout: fixed; width: 100%;">
<colgroup>
<col style="width: 25%;">
<col style="width: 25%;">
<col style="width: 25%;">
<col style="width: 25%;">
</colgroup>
<thead>
<tr>
<th>🎵 MP3</th>
<th>🎧 AAC</th>
<th>📱 Low</th>
<th>📈 Total</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: center;"><span class="stat-number" id="listeners-mp3">0</span></td>
<td style="text-align: center;"><span class="stat-number" id="listeners-aac">0</span></td>
<td style="text-align: center;"><span class="stat-number" id="listeners-low">0</span></td>
<td style="text-align: center;"><span class="stat-number" id="listeners-total">0</span></td>
</tr>
<tr class="stat-peak-row">
<td style="text-align: center;">Peak: <span id="peak-mp3">0</span></td>
<td style="text-align: center;">Peak: <span id="peak-aac">0</span></td>
<td style="text-align: center;">Peak: <span id="peak-low">0</span></td>
<td style="text-align: center;">Updated: <span id="stats-updated">--</span></td>
</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>
</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>
<!-- 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
setInterval(refreshListenerStats, 30000);
setInterval(refreshGeoStats, 60000);
// Initial load
document.addEventListener('DOMContentLoaded', function() {
refreshListenerStats();
refreshGeoStats();
});
// Admin password reset handler