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:
parent
4be3b83da1
commit
c89e31b998
14
TODO.org
14
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
|
||||
|
|
|
|||
|
|
@ -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 ()
|
||||
|
|
|
|||
|
|
@ -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)))))
|
||||
|
|
|
|||
|
|
@ -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;;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue