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))))
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
(defun start-server (&key (port *server-port*))

View File

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

View File

@ -265,18 +265,19 @@
(error (e)
(log:error "Session cleanup failed: ~a" e))))
(defun update-geo-stats (country-code listener-count)
"Update geo stats for today"
(defun update-geo-stats (country-code listener-count &optional city)
"Update geo stats for today, optionally including city"
(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)
(let ((city-sql (if city (format nil "'~a'" city) "NULL")))
(postmodern:execute
(format nil "INSERT INTO listener_geo_stats (date, country_code, city, listener_count, listen_minutes)
VALUES (CURRENT_DATE, '~a', ~a, ~a, 1)
ON CONFLICT (date, country_code, city)
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)))
country-code city-sql listener-count listener-count))))
(error (e)
(log:error "Failed to update geo stats: ~a" e)))))
@ -381,6 +382,22 @@
(log:error "Failed to get geo stats: ~a" e)
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)
"Get listening statistics for a specific user"
(handler-case
@ -402,52 +419,55 @@
;;; Polling Service
(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))
(cached (gethash ip-hash *geo-cache*)))
(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
(let ((geo (lookup-geoip ip)))
(when geo
(let ((country (getf geo :country-code)))
(let ((country (getf geo :country-code))
(city (getf geo :city)))
(setf (gethash ip-hash *geo-cache*)
(list :country country :time (get-universal-time)))
country))))))
(list :country country :city city :time (get-universal-time)))
(cons country city)))))))
(defun collect-geo-stats-for-mount (mount)
"Collect geo stats for all listeners on a mount (from Icecast - may show proxy IPs)"
(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
(location-counts (make-hash-table :test 'equal)))
;; Group by country+city
(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)))))
(let ((geo (get-cached-geo ip))) ; Returns (country . city) or nil
(when geo
(incf (gethash geo location-counts 0)))))
;; Store each country+city count
(maphash (lambda (key count)
(update-geo-stats (car key) count (cdr key)))
location-counts)))))
(defun collect-geo-stats-from-web-listeners ()
"Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)"
(cleanup-stale-web-listeners)
(let ((country-counts (make-hash-table :test 'equal)))
;; Count listeners by country from cached geo data
(let ((location-counts (make-hash-table :test 'equal)))
;; Count listeners by country+city from cached geo data
(maphash (lambda (session-id data)
(declare (ignore session-id))
(let* ((ip-hash (getf data :ip-hash))
(cached-geo (gethash ip-hash *geo-cache*))
(country (when cached-geo (getf cached-geo :country))))
(when country
(incf (gethash country country-counts 0)))))
(country (when cached-geo (getf cached-geo :country)))
(city (when cached-geo (getf cached-geo :city)))
(key (when country (cons country city))))
(when key
(incf (gethash key location-counts 0)))))
*web-listeners*)
;; Store each country's count
(maphash (lambda (country count)
(update-geo-stats country count))
country-counts)))
;; Store each country+city count
(maphash (lambda (key count)
(update-geo-stats (car key) count (cdr key)))
location-counts)))
(defun poll-and-store-stats ()
"Single poll iteration: fetch stats and store"

View File

@ -311,38 +311,61 @@
(defun attach-audio-event-listeners (audio-element)
"Attach all necessary event listeners to an audio element"
;; Error handler
;; Error handler - retry indefinitely with exponential backoff
(ps:chain audio-element
(add-event-listener "error"
(lambda (err)
(incf *stream-error-count*)
(ps:chain console (log "Stream error:" err))
(if (< *stream-error-count* 3)
;; Auto-retry for first few errors
(progn
(show-stream-status (+ "⚠️ Stream error. Reconnecting... (attempt " *stream-error-count* ")") "warning")
(setf *reconnect-timeout*
(set-timeout reconnect-stream 3000)))
;; Too many errors, show manual reconnect
(progn
(show-stream-status "❌ Stream connection lost. Click Reconnect to try again." "error")
(show-reconnect-button))))))
(when *is-reconnecting*
(return))
(incf *stream-error-count*)
;; Calculate delay with exponential backoff (3s, 6s, 12s, max 30s)
(let ((delay (ps:chain |Math| (min (* 3000 (ps:chain |Math| (pow 2 (- *stream-error-count* 1)))) 30000))))
(show-stream-status (+ "⚠️ Stream error. Reconnecting in " (/ delay 1000) "s... (attempt " *stream-error-count* ")") "warning")
(setf *is-reconnecting* t)
(setf *reconnect-timeout*
(set-timeout reconnect-stream delay))))))
;; Stalled handler
(ps:chain audio-element
(add-event-listener "stalled"
(lambda ()
(ps:chain console (log "Stream stalled"))
(show-stream-status "⚠️ Stream stalled. Attempting to recover..." "warning")
(when *is-reconnecting*
(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*
(set-timeout
(lambda ()
;; Only reconnect if still stalled
(when (ps:@ audio-element paused)
(reconnect-stream)))
(if (< (ps:@ audio-element ready-state) 3)
(reconnect-stream)
(setf *is-reconnecting* false)))
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)
(ps:chain audio-element
(add-event-listener "waiting"
@ -355,6 +378,7 @@
(add-event-listener "playing"
(lambda ()
(setf *stream-error-count* 0)
(setf *is-reconnecting* false)
(hide-stream-status)
(hide-reconnect-button)
(when *reconnect-timeout*

View File

@ -336,6 +336,9 @@
return String.fromCodePoint(...codePoints);
}
// Track expanded countries
const expandedCountries = new Set();
// Fetch and display geo stats
function refreshGeoStats() {
fetch('/api/asteroid/stats/geo?days=7')
@ -349,12 +352,19 @@
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>
const isExpanded = expandedCountries.has(country);
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>${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>`;
}).join('');
// Re-fetch cities for expanded countries
expandedCountries.forEach(country => fetchCities(country));
} else {
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
setInterval(refreshListenerStats, 30000);
setInterval(refreshGeoStats, 60000);

View File

@ -54,18 +54,8 @@
});
}
// Add event listeners for debugging
audioElement.addEventListener('waiting', function() {
console.log('Audio buffering...');
});
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
});
audioElement.addEventListener('error', function(e) {
console.error('Audio error:', e);
});
// Note: Main event listeners are attached via attachAudioListeners()
// which is called at the bottom of this script
const selector = document.getElementById('stream-quality');
const streamQuality = localStorage.getItem('stream-quality') || 'aac';
@ -251,6 +241,11 @@
}, 300);
}
// Error retry counter and reconnect state
var streamErrorCount = 0;
var reconnectTimeout = null;
var isReconnecting = false;
// Attach event listeners to audio element
function attachAudioListeners(audioElement) {
audioElement.addEventListener('waiting', function() {
@ -260,16 +255,63 @@
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
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) {
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() {
console.log('Audio stalled');
showStatus('⚠️ Stream stalled - click 🔄 if no audio', true);
if (isReconnecting) return; // Already reconnecting, skip
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
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) {
console.log('Stream error, attempting reconnect in 3 seconds...');
setTimeout(function() {
console.error('Audio error:', e);
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.play().catch(err => console.log('Reconnect failed:', err));
}, 3000);
}, delay);
});
audioElement.addEventListener('stalled', function() {
console.log('Stream stalled, reloading...');
audioElement.load();
audioElement.play().catch(err => console.log('Reload failed:', err));
if (isReconnecting) return;
console.log('Stream stalled, will auto-reconnect in 5 seconds...');
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