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
This commit is contained in:
parent
8a0b1b346c
commit
474e9c6176
|
|
@ -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)))))
|
||||||
|
|
||||||
|
|
@ -402,52 +403,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"
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS listener_geo_stats (
|
||||||
city VARCHAR(100),
|
city VARCHAR(100),
|
||||||
listener_count INTEGER DEFAULT 0,
|
listener_count INTEGER DEFAULT 0,
|
||||||
listen_minutes INTEGER DEFAULT 0,
|
listen_minutes INTEGER DEFAULT 0,
|
||||||
UNIQUE(date, country_code)
|
UNIQUE(date, country_code, city)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_geo_stats_date ON listener_geo_stats(date);
|
CREATE INDEX IF NOT EXISTS idx_geo_stats_date ON listener_geo_stats(date);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue