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 |
+
+
+
+
+ | 0 |
+ 0 |
+ 0 |
+ 0 |
+
+
+ | Peak: 0 |
+ Peak: 0 |
+ Peak: 0 |
+ Updated: -- |
+
+
+
+
+
+
+
+
🌍 Listener Locations (Last 7 Days)
+
+
+
+
+ | Country |
+ Listeners |
+ Minutes |
+
+
+
+ | 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