Compare commits

...

7 Commits

Author SHA1 Message Date
Glenn Thompson a11f64b636 Add expandable city breakdown in geo stats admin view
- Add get-geo-stats-by-city function for city-level queries
- Add /api/asteroid/stats/geo/cities endpoint
- Add expandable country rows in admin geo stats table
- Click country to expand/collapse city breakdown
2025-12-12 13:55:55 -05:00
Glenn Thompson 75b27c5424 Add city-level tracking to geo stats
- Update update-geo-stats to accept optional city parameter
- Update get-cached-geo to cache and return city along with country
- Update collect-geo-stats-for-mount and collect-geo-stats-from-web-listeners
  to track by country+city
- Revert migration to keep UNIQUE(date, country_code, city) constraint
2025-12-12 13:55:55 -05:00
Glenn Thompson 009e812f8c Fix listener_geo_stats unique constraint to match code
The ON CONFLICT clause uses (date, country_code) but the table had
UNIQUE(date, country_code, city). Changed to UNIQUE(date, country_code).
2025-12-12 13:55:55 -05:00
Glenn Thompson b29e504bb3 Add pause event handler for muted stream throttling
- Detect when browser pauses muted stream (throttling)
- Auto-reconnect after 3 seconds when paused while muted
- Apply to all three players for consistency
2025-12-12 13:55:55 -05:00
Glenn Thompson 2cd128260c Add robust auto-reconnect to all audio players
- Implement isReconnecting flag to prevent duplicate reconnect attempts
- Add exponential backoff for error retries (3s, 6s, 12s, max 30s)
- Retry indefinitely until stream returns
- Handle error, stalled, and ended events consistently
- Reset state on successful playback
- Apply same logic to frame player, popout player, and front-page player
2025-12-12 13:55:55 -05:00
Brian O'Reilly 8b0f7e7705 change the icecast burst size
one of the accepted remediations for 'stuck stuttering stream' is to
change the burst size for the icecast server. Changing this value as a
test to see if it helps the problem.
2025-12-12 12:59:04 -05:00
Glenn Thompson edb17a71c4 Add city-level tracking to geo stats
- Update update-geo-stats to accept optional city parameter
- Update get-cached-geo to cache and return city along with country
- Update collect-geo-stats-for-mount and collect-geo-stats-from-web-listeners
  to track by country+city
- Revert migration to keep UNIQUE(date, country_code, city) constraint
2025-12-12 11:38:40 -05:00
7 changed files with 274 additions and 71 deletions

View File

@ -1281,6 +1281,18 @@
("total_minutes" . ,(third row)))) ("total_minutes" . ,(third row))))
stats)))))) stats))))))
(define-api asteroid/stats/geo/cities (country &optional (days "7")) ()
"Get city breakdown for a specific country (admin only)"
(require-role :admin)
(let ((stats (get-geo-stats-by-city country (parse-integer days :junk-allowed t))))
(api-output `(("status" . "success")
("country" . ,country)
("cities" . ,(mapcar (lambda (row)
`(("city" . ,(or (first row) "Unknown"))
("listeners" . ,(second row))
("minutes" . ,(third row))))
stats))))))
;; RADIANCE server management functions ;; RADIANCE server management functions
(defun start-server (&key (port *server-port*)) (defun start-server (&key (port *server-port*))

View File

@ -10,7 +10,7 @@
<header-timeout>15</header-timeout> <header-timeout>15</header-timeout>
<source-timeout>10</source-timeout> <source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect> <burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size> <burst-size>500000</burst-size>
</limits> </limits>
<authentication> <authentication>

View File

@ -265,18 +265,19 @@
(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) (defun update-geo-stats (country-code listener-count &optional city)
"Update geo stats for today" "Update geo stats for today, optionally including city"
(when country-code (when country-code
(handler-case (handler-case
(with-db (with-db
(postmodern:execute (let ((city-sql (if city (format nil "'~a'" city) "NULL")))
(format nil "INSERT INTO listener_geo_stats (date, country_code, listener_count, listen_minutes) (postmodern:execute
VALUES (CURRENT_DATE, '~a', ~a, 1) (format nil "INSERT INTO listener_geo_stats (date, country_code, city, listener_count, listen_minutes)
ON CONFLICT (date, country_code) VALUES (CURRENT_DATE, '~a', ~a, ~a, 1)
ON CONFLICT (date, country_code, city)
DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a, DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a,
listen_minutes = listener_geo_stats.listen_minutes + 1" listen_minutes = listener_geo_stats.listen_minutes + 1"
country-code listener-count listener-count))) country-code city-sql listener-count listener-count))))
(error (e) (error (e)
(log:error "Failed to update geo stats: ~a" e))))) (log:error "Failed to update geo stats: ~a" e)))))
@ -381,6 +382,22 @@
(log:error "Failed to get geo stats: ~a" e) (log:error "Failed to get geo stats: ~a" e)
nil))) nil)))
(defun get-geo-stats-by-city (country-code &optional (days 7))
"Get city breakdown for a specific country for the last N days"
(handler-case
(with-db
(postmodern:query
(format nil "SELECT city, SUM(listener_count) as total_listeners, SUM(listen_minutes) as total_minutes
FROM listener_geo_stats
WHERE date > NOW() - INTERVAL '~a days'
AND country_code = '~a'
GROUP BY city
ORDER BY total_listeners DESC
LIMIT 10" days country-code)))
(error (e)
(log:error "Failed to get city stats for ~a: ~a" country-code e)
nil)))
(defun get-user-listening-stats (user-id) (defun get-user-listening-stats (user-id)
"Get listening statistics for a specific user" "Get listening statistics for a specific user"
(handler-case (handler-case
@ -402,52 +419,55 @@
;;; Polling Service ;;; Polling Service
(defun get-cached-geo (ip) (defun get-cached-geo (ip)
"Get cached geo data for IP, or lookup and cache" "Get cached geo data for IP, or lookup and cache. Returns (country . city) or nil."
(let* ((ip-hash (hash-ip-address ip)) (let* ((ip-hash (hash-ip-address ip))
(cached (gethash ip-hash *geo-cache*))) (cached (gethash ip-hash *geo-cache*)))
(if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*)) (if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*))
(getf cached :country) (cons (getf cached :country) (getf cached :city))
;; Lookup and cache ;; Lookup and cache
(let ((geo (lookup-geoip ip))) (let ((geo (lookup-geoip ip)))
(when geo (when geo
(let ((country (getf geo :country-code))) (let ((country (getf geo :country-code))
(city (getf geo :city)))
(setf (gethash ip-hash *geo-cache*) (setf (gethash ip-hash *geo-cache*)
(list :country country :time (get-universal-time))) (list :country country :city city :time (get-universal-time)))
country)))))) (cons country city)))))))
(defun collect-geo-stats-for-mount (mount) (defun collect-geo-stats-for-mount (mount)
"Collect geo stats for all listeners on a mount (from Icecast - may show proxy IPs)" "Collect geo stats for all listeners on a mount (from Icecast - may show proxy IPs)"
(let ((listclients-xml (fetch-icecast-listclients mount))) (let ((listclients-xml (fetch-icecast-listclients mount)))
(when listclients-xml (when listclients-xml
(let ((ips (extract-listener-ips listclients-xml)) (let ((ips (extract-listener-ips listclients-xml))
(country-counts (make-hash-table :test 'equal))) (location-counts (make-hash-table :test 'equal)))
;; Group by country ;; Group by country+city
(dolist (ip ips) (dolist (ip ips)
(let ((country (get-cached-geo ip))) (let ((geo (get-cached-geo ip))) ; Returns (country . city) or nil
(when country (when geo
(incf (gethash country country-counts 0))))) (incf (gethash geo location-counts 0)))))
;; Store each country's count ;; Store each country+city count
(maphash (lambda (country count) (maphash (lambda (key count)
(update-geo-stats country count)) (update-geo-stats (car key) count (cdr key)))
country-counts))))) location-counts)))))
(defun collect-geo-stats-from-web-listeners () (defun collect-geo-stats-from-web-listeners ()
"Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)" "Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)"
(cleanup-stale-web-listeners) (cleanup-stale-web-listeners)
(let ((country-counts (make-hash-table :test 'equal))) (let ((location-counts (make-hash-table :test 'equal)))
;; Count listeners by country from cached geo data ;; Count listeners by country+city from cached geo data
(maphash (lambda (session-id data) (maphash (lambda (session-id data)
(declare (ignore session-id)) (declare (ignore session-id))
(let* ((ip-hash (getf data :ip-hash)) (let* ((ip-hash (getf data :ip-hash))
(cached-geo (gethash ip-hash *geo-cache*)) (cached-geo (gethash ip-hash *geo-cache*))
(country (when cached-geo (getf cached-geo :country)))) (country (when cached-geo (getf cached-geo :country)))
(when country (city (when cached-geo (getf cached-geo :city)))
(incf (gethash country country-counts 0))))) (key (when country (cons country city))))
(when key
(incf (gethash key location-counts 0)))))
*web-listeners*) *web-listeners*)
;; Store each country's count ;; Store each country+city count
(maphash (lambda (country count) (maphash (lambda (key count)
(update-geo-stats country count)) (update-geo-stats (car key) count (cdr key)))
country-counts))) location-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"

View File

@ -311,38 +311,61 @@
(defun attach-audio-event-listeners (audio-element) (defun attach-audio-event-listeners (audio-element)
"Attach all necessary event listeners to an audio element" "Attach all necessary event listeners to an audio element"
;; Error handler ;; Error handler - retry indefinitely with exponential backoff
(ps:chain audio-element (ps:chain audio-element
(add-event-listener "error" (add-event-listener "error"
(lambda (err) (lambda (err)
(incf *stream-error-count*)
(ps:chain console (log "Stream error:" err)) (ps:chain console (log "Stream error:" err))
(when *is-reconnecting*
(if (< *stream-error-count* 3) (return))
;; Auto-retry for first few errors (incf *stream-error-count*)
(progn ;; Calculate delay with exponential backoff (3s, 6s, 12s, max 30s)
(show-stream-status (+ "⚠️ Stream error. Reconnecting... (attempt " *stream-error-count* ")") "warning") (let ((delay (ps:chain |Math| (min (* 3000 (ps:chain |Math| (pow 2 (- *stream-error-count* 1)))) 30000))))
(setf *reconnect-timeout* (show-stream-status (+ "⚠️ Stream error. Reconnecting in " (/ delay 1000) "s... (attempt " *stream-error-count* ")") "warning")
(set-timeout reconnect-stream 3000))) (setf *is-reconnecting* t)
;; Too many errors, show manual reconnect (setf *reconnect-timeout*
(progn (set-timeout reconnect-stream delay))))))
(show-stream-status "❌ Stream connection lost. Click Reconnect to try again." "error")
(show-reconnect-button))))))
;; Stalled handler ;; Stalled handler
(ps:chain audio-element (ps:chain audio-element
(add-event-listener "stalled" (add-event-listener "stalled"
(lambda () (lambda ()
(ps:chain console (log "Stream stalled")) (when *is-reconnecting*
(show-stream-status "⚠️ Stream stalled. Attempting to recover..." "warning") (return))
(ps:chain console (log "Stream stalled, will auto-reconnect in 5 seconds..."))
(show-stream-status "⚠️ Stream stalled - reconnecting..." "warning")
(setf *is-reconnecting* t)
(setf *reconnect-timeout* (setf *reconnect-timeout*
(set-timeout (set-timeout
(lambda () (lambda ()
;; Only reconnect if still stalled ;; Only reconnect if still stalled
(when (ps:@ audio-element paused) (if (< (ps:@ audio-element ready-state) 3)
(reconnect-stream))) (reconnect-stream)
(setf *is-reconnecting* false)))
5000))))) 5000)))))
;; Ended handler - stream shouldn't end, so reconnect
(ps:chain audio-element
(add-event-listener "ended"
(lambda ()
(when *is-reconnecting*
(return))
(ps:chain console (log "Stream ended unexpectedly, reconnecting..."))
(show-stream-status "⚠️ Stream ended - reconnecting..." "warning")
(setf *is-reconnecting* t)
(set-timeout reconnect-stream 2000))))
;; Pause handler - detect browser throttling muted streams
(ps:chain audio-element
(add-event-listener "pause"
(lambda ()
(when (and (ps:@ audio-element muted)
(not *is-reconnecting*))
(ps:chain console (log "Stream paused while muted (possible browser throttling), reconnecting..."))
(show-stream-status "⚠️ Stream paused - reconnecting..." "warning")
(setf *is-reconnecting* t)
(set-timeout reconnect-stream 3000)))))
;; Waiting handler (buffering) ;; Waiting handler (buffering)
(ps:chain audio-element (ps:chain audio-element
(add-event-listener "waiting" (add-event-listener "waiting"
@ -355,6 +378,7 @@
(add-event-listener "playing" (add-event-listener "playing"
(lambda () (lambda ()
(setf *stream-error-count* 0) (setf *stream-error-count* 0)
(setf *is-reconnecting* false)
(hide-stream-status) (hide-stream-status)
(hide-reconnect-button) (hide-reconnect-button)
(when *reconnect-timeout* (when *reconnect-timeout*

View File

@ -336,6 +336,9 @@
return String.fromCodePoint(...codePoints); return String.fromCodePoint(...codePoints);
} }
// Track expanded countries
const expandedCountries = new Set();
// Fetch and display geo stats // Fetch and display geo stats
function refreshGeoStats() { function refreshGeoStats() {
fetch('/api/asteroid/stats/geo?days=7') fetch('/api/asteroid/stats/geo?days=7')
@ -349,12 +352,19 @@
const country = item.country_code || item[0]; const country = item.country_code || item[0];
const listeners = item.total_listeners || item[1] || 0; const listeners = item.total_listeners || item[1] || 0;
const minutes = item.total_minutes || item[2] || 0; const minutes = item.total_minutes || item[2] || 0;
return `<tr> const isExpanded = expandedCountries.has(country);
<td>${countryToFlag(country)} ${country}</td> const arrow = isExpanded ? '▼' : '▶';
return `<tr class="country-row" data-country="${country}" style="cursor: pointer;" onclick="toggleCountryCities('${country}')">
<td><span class="expand-arrow">${arrow}</span> ${countryToFlag(country)} ${country}</td>
<td>${listeners}</td> <td>${listeners}</td>
<td>${minutes}</td> <td>${minutes}</td>
</tr>
<tr class="city-rows" id="cities-${country}" style="display: ${isExpanded ? 'table-row' : 'none'};">
<td colspan="3" style="padding: 0;"><div class="city-container" id="city-container-${country}"></div></td>
</tr>`; </tr>`;
}).join(''); }).join('');
// Re-fetch cities for expanded countries
expandedCountries.forEach(country => fetchCities(country));
} else { } else {
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>'; tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
} }
@ -366,6 +376,52 @@
}); });
} }
// Toggle city display for a country
function toggleCountryCities(country) {
const cityRow = document.getElementById(`cities-${country}`);
const countryRow = document.querySelector(`tr[data-country="${country}"]`);
const arrow = countryRow.querySelector('.expand-arrow');
if (expandedCountries.has(country)) {
expandedCountries.delete(country);
cityRow.style.display = 'none';
arrow.textContent = '▶';
} else {
expandedCountries.add(country);
cityRow.style.display = 'table-row';
arrow.textContent = '▼';
fetchCities(country);
}
}
// Fetch cities for a country
function fetchCities(country) {
const container = document.getElementById(`city-container-${country}`);
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">Loading cities...</div>';
fetch(`/api/asteroid/stats/geo/cities?country=${country}&days=7`)
.then(response => response.json())
.then(result => {
const data = result.data || result;
if (data.status === 'success' && data.cities && data.cities.length > 0) {
container.innerHTML = '<table style="width: 100%; margin-left: 20px;">' +
data.cities.map(city => `
<tr style="background: rgba(0,255,0,0.05);">
<td style="padding: 3px 10px;">└ ${city.city}</td>
<td style="padding: 3px 10px;">${city.listeners}</td>
<td style="padding: 3px 10px;">${city.minutes}</td>
</tr>
`).join('') + '</table>';
} else {
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">No city data</div>';
}
})
.catch(error => {
console.error('Error fetching cities:', error);
container.innerHTML = '<div style="padding: 5px 20px; color: #ff6666;">Error loading cities</div>';
});
}
// Auto-refresh stats every 30 seconds // Auto-refresh stats every 30 seconds
setInterval(refreshListenerStats, 30000); setInterval(refreshListenerStats, 30000);
setInterval(refreshGeoStats, 60000); setInterval(refreshGeoStats, 60000);

View File

@ -54,18 +54,8 @@
}); });
} }
// Add event listeners for debugging // Note: Main event listeners are attached via attachAudioListeners()
audioElement.addEventListener('waiting', function() { // which is called at the bottom of this script
console.log('Audio buffering...');
});
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
});
audioElement.addEventListener('error', function(e) {
console.error('Audio error:', e);
});
const selector = document.getElementById('stream-quality'); const selector = document.getElementById('stream-quality');
const streamQuality = localStorage.getItem('stream-quality') || 'aac'; const streamQuality = localStorage.getItem('stream-quality') || 'aac';
@ -251,6 +241,11 @@
}, 300); }, 300);
} }
// Error retry counter and reconnect state
var streamErrorCount = 0;
var reconnectTimeout = null;
var isReconnecting = false;
// Attach event listeners to audio element // Attach event listeners to audio element
function attachAudioListeners(audioElement) { function attachAudioListeners(audioElement) {
audioElement.addEventListener('waiting', function() { audioElement.addEventListener('waiting', function() {
@ -260,16 +255,63 @@
audioElement.addEventListener('playing', function() { audioElement.addEventListener('playing', function() {
console.log('Audio playing'); console.log('Audio playing');
hideStatus(); hideStatus();
streamErrorCount = 0; // Reset error count on successful play
isReconnecting = false; // Reset reconnecting flag
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
}); });
audioElement.addEventListener('error', function(e) { audioElement.addEventListener('error', function(e) {
console.error('Audio error:', e); console.error('Audio error:', e);
showStatus('⚠️ Stream error - click 🔄 to reconnect', true); if (isReconnecting) return; // Already reconnecting, skip
streamErrorCount++;
// Calculate delay with exponential backoff (3s, 6s, 12s, max 30s)
var delay = Math.min(3000 * Math.pow(2, streamErrorCount - 1), 30000);
showStatus('⚠️ Stream error. Reconnecting in ' + (delay/1000) + 's... (attempt ' + streamErrorCount + ')', true);
isReconnecting = true;
reconnectTimeout = setTimeout(function() {
reconnectStream();
}, delay);
}); });
audioElement.addEventListener('stalled', function() { audioElement.addEventListener('stalled', function() {
console.log('Audio stalled'); if (isReconnecting) return; // Already reconnecting, skip
showStatus('⚠️ Stream stalled - click 🔄 if no audio', true); console.log('Audio stalled, will auto-reconnect in 5 seconds...');
showStatus('⚠️ Stream stalled - reconnecting...', true);
isReconnecting = true;
setTimeout(function() {
if (audioElement.readyState < 3) {
reconnectStream();
} else {
isReconnecting = false;
}
}, 5000);
});
// Handle ended event - stream shouldn't end, so reconnect
audioElement.addEventListener('ended', function() {
if (isReconnecting) return; // Already reconnecting, skip
console.log('Stream ended unexpectedly, reconnecting...');
showStatus('⚠️ Stream ended - reconnecting...', true);
isReconnecting = true;
setTimeout(function() {
reconnectStream();
}, 2000);
});
// Handle pause event - detect browser throttling muted streams
audioElement.addEventListener('pause', function() {
// If paused while muted and we didn't initiate it, browser may have throttled
if (audioElement.muted && !isReconnecting) {
console.log('Stream paused while muted (possible browser throttling), will reconnect in 3 seconds...');
showStatus('⚠️ Stream paused - reconnecting...', true);
isReconnecting = true;
setTimeout(function() {
reconnectStream();
}, 3000);
}
}); });
} }

View File

@ -127,18 +127,67 @@
// Auto-reconnect on stream errors // Auto-reconnect on stream errors
const audioElement = document.getElementById('live-audio'); const audioElement = document.getElementById('live-audio');
var streamErrorCount = 0;
var reconnectTimeout = null;
var isReconnecting = false;
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
streamErrorCount = 0;
isReconnecting = false;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
});
audioElement.addEventListener('error', function(e) { audioElement.addEventListener('error', function(e) {
console.log('Stream error, attempting reconnect in 3 seconds...'); console.error('Audio error:', e);
setTimeout(function() { if (isReconnecting) return;
streamErrorCount++;
var delay = Math.min(3000 * Math.pow(2, streamErrorCount - 1), 30000);
console.log('Stream error. Reconnecting in ' + (delay/1000) + 's... (attempt ' + streamErrorCount + ')');
isReconnecting = true;
reconnectTimeout = setTimeout(function() {
audioElement.load(); audioElement.load();
audioElement.play().catch(err => console.log('Reconnect failed:', err)); audioElement.play().catch(err => console.log('Reconnect failed:', err));
}, 3000); }, delay);
}); });
audioElement.addEventListener('stalled', function() { audioElement.addEventListener('stalled', function() {
console.log('Stream stalled, reloading...'); if (isReconnecting) return;
audioElement.load(); console.log('Stream stalled, will auto-reconnect in 5 seconds...');
audioElement.play().catch(err => console.log('Reload failed:', err)); isReconnecting = true;
setTimeout(function() {
if (audioElement.readyState < 3) {
audioElement.load();
audioElement.play().catch(err => console.log('Reload failed:', err));
} else {
isReconnecting = false;
}
}, 5000);
});
audioElement.addEventListener('ended', function() {
if (isReconnecting) return;
console.log('Stream ended unexpectedly, reconnecting...');
isReconnecting = true;
setTimeout(function() {
audioElement.load();
audioElement.play().catch(err => console.log('Reconnect failed:', err));
}, 2000);
});
// Handle pause event - detect browser throttling muted streams
audioElement.addEventListener('pause', function() {
if (audioElement.muted && !isReconnecting) {
console.log('Stream paused while muted (possible browser throttling), reconnecting...');
isReconnecting = true;
setTimeout(function() {
audioElement.load();
audioElement.play().catch(err => console.log('Reconnect failed:', err));
}, 3000);
}
}); });
// Notify parent window that popout is open // Notify parent window that popout is open