diff --git a/asteroid.asd b/asteroid.asd
index 9704836..d60e402 100644
--- a/asteroid.asd
+++ b/asteroid.asd
@@ -58,6 +58,7 @@
(:file "user-management")
(:file "playlist-management")
(:file "stream-control")
+ (:file "listener-stats")
(:file "auth-routes")
(:file "frontend-partials")
(:file "asteroid")))
diff --git a/asteroid.lisp b/asteroid.lisp
index 0f947fe..0926c70 100644
--- a/asteroid.lisp
+++ b/asteroid.lisp
@@ -823,11 +823,16 @@
(define-api asteroid/user/listening-stats () ()
"Get user listening statistics"
(require-authentication)
- (api-output `(("status" . "success")
- ("stats" . (("total_listen_time" . 0)
- ("tracks_played" . 0)
- ("session_count" . 0)
- ("favorite_genre" . "Unknown"))))))
+ (let* ((current-user (get-current-user))
+ (user-id (when current-user (dm:id current-user)))
+ (stats (if user-id
+ (get-user-listening-stats user-id)
+ (list :total-listen-time 0 :session-count 0 :tracks-played 0))))
+ (api-output `(("status" . "success")
+ ("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0))
+ ("tracks_played" . ,(getf stats :tracks-played 0))
+ ("session_count" . ,(getf stats :session-count 0))
+ ("favorite_genre" . "Unknown")))))))
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
"Get recently played tracks for user"
@@ -1000,6 +1005,39 @@
`(("error" . "Could not connect to Icecast server"))
:status 503)))))
+;;; Listener Statistics API Endpoints
+
+(define-api asteroid/stats/current () ()
+ "Get current listener count from recent snapshots"
+ (let ((listeners (get-current-listeners)))
+ (api-output `(("status" . "success")
+ ("listeners" . ,listeners)
+ ("timestamp" . ,(get-universal-time))))))
+
+(define-api asteroid/stats/daily (&optional (days "30")) ()
+ "Get daily listener statistics (admin only)"
+ (require-role :admin)
+ (let ((stats (get-daily-stats (parse-integer days :junk-allowed t))))
+ (api-output `(("status" . "success")
+ ("stats" . ,(mapcar (lambda (row)
+ `(("date" . ,(first row))
+ ("mount" . ,(second row))
+ ("unique_listeners" . ,(third row))
+ ("peak_concurrent" . ,(fourth row))
+ ("total_listen_minutes" . ,(fifth row))
+ ("avg_session_minutes" . ,(sixth row))))
+ stats))))))
+
+(define-api asteroid/stats/geo (&optional (days "7")) ()
+ "Get geographic distribution of listeners (admin only)"
+ (require-role :admin)
+ (let ((stats (get-geo-stats (parse-integer days :junk-allowed t))))
+ (api-output `(("status" . "success")
+ ("geo" . ,(mapcar (lambda (row)
+ `(("country_code" . ,(first row))
+ ("total_listeners" . ,(second row))
+ ("total_minutes" . ,(third row))))
+ stats))))))
;; RADIANCE server management functions
@@ -1012,11 +1050,24 @@
;; (unless (radiance:environment)
;; (setf (radiance:environment) "asteroid"))
- (radiance:startup))
+ (radiance:startup)
+
+ ;; Start listener statistics polling
+ (handler-case
+ (progn
+ (format t "Starting listener statistics polling...~%")
+ (start-stats-polling))
+ (error (e)
+ (format t "Warning: Could not start stats polling: ~a~%" e))))
(defun stop-server ()
"Stop the Asteroid Radio RADIANCE server"
(format t "Stopping Asteroid Radio server...~%")
+ ;; Stop listener statistics polling
+ (handler-case
+ (stop-stats-polling)
+ (error (e)
+ (format t "Warning: Error stopping stats polling: ~a~%" e)))
(radiance:shutdown)
(format t "Server stopped.~%"))
diff --git a/listener-stats.lisp b/listener-stats.lisp
new file mode 100644
index 0000000..cf5b3e0
--- /dev/null
+++ b/listener-stats.lisp
@@ -0,0 +1,352 @@
+;;;; listener-stats.lisp - Listener Statistics Collection Service
+;;;; Polls Icecast for listener data and stores with GDPR compliance
+
+(in-package #:asteroid)
+
+;;; Use postmodern for direct SQL queries
+;;; Connection params from environment or defaults matching config/radiance-postgres.lisp
+(defun get-db-connection-params ()
+ "Get database connection parameters"
+ (list (or (uiop:getenv "ASTEROID_DB_NAME") "asteroid")
+ (or (uiop:getenv "ASTEROID_DB_USER") "asteroid")
+ (or (uiop:getenv "ASTEROID_DB_PASSWORD") "asteroid_db_2025")
+ "localhost"
+ :port 5432))
+
+(defmacro with-db (&body body)
+ "Execute body with database connection"
+ `(postmodern:with-connection (get-db-connection-params)
+ ,@body))
+
+;;; Configuration
+(defvar *stats-polling-interval* 60
+ "Seconds between Icecast polls")
+
+(defvar *stats-polling-thread* nil
+ "Background thread for polling")
+
+(defvar *stats-polling-active* nil
+ "Flag to control polling loop")
+
+(defvar *icecast-stats-url* "http://localhost:8000/admin/stats"
+ "Icecast admin stats endpoint (XML)")
+
+(defvar *icecast-admin-user* "admin"
+ "Icecast admin username")
+
+(defvar *icecast-admin-pass* "asteroid_admin_2024"
+ "Icecast admin password")
+
+(defvar *geoip-api-url* "http://ip-api.com/json/~a?fields=status,countryCode,city,regionName"
+ "GeoIP lookup API (free tier: 45 req/min)")
+
+(defvar *session-retention-days* 30
+ "Days to retain individual session data")
+
+;;; Active listener tracking (in-memory)
+(defvar *active-listeners* (make-hash-table :test 'equal)
+ "Hash table tracking active listeners by IP hash")
+
+;;; Utility Functions
+
+(defun hash-ip-address (ip-address)
+ "Hash an IP address using SHA256 for privacy-safe storage"
+ (let ((digest (ironclad:make-digest :sha256)))
+ (ironclad:update-digest digest (babel:string-to-octets ip-address))
+ (ironclad:byte-array-to-hex-string (ironclad:produce-digest digest))))
+
+(defun generate-session-id ()
+ "Generate a unique session ID"
+ (let ((digest (ironclad:make-digest :sha256)))
+ (ironclad:update-digest digest
+ (babel:string-to-octets
+ (format nil "~a-~a" (get-universal-time) (random 1000000))))
+ (subseq (ironclad:byte-array-to-hex-string (ironclad:produce-digest digest)) 0 32)))
+
+;;; GeoIP Lookup
+
+(defun lookup-geoip (ip-address)
+ "Look up geographic location for an IP address.
+ Returns plist with :country-code :city :region or NIL on failure.
+ Note: Does not store the raw IP - only uses it for lookup."
+ (handler-case
+ (let* ((url (format nil *geoip-api-url* ip-address))
+ (response (drakma:http-request url :want-stream nil))
+ (data (cl-json:decode-json-from-string response)))
+ (when (string= (cdr (assoc :status data)) "success")
+ (list :country-code (cdr (assoc :country-code data))
+ :city (cdr (assoc :city data))
+ :region (cdr (assoc :region-name data)))))
+ (error (e)
+ (log:debug "GeoIP lookup failed for ~a: ~a" ip-address e)
+ nil)))
+
+;;; Icecast Polling
+
+(defun extract-xml-value (xml tag)
+ "Extract value between XML tags. Simple regex-based extraction."
+ (let ((pattern (format nil "<~a>([^<]*)~a>" tag tag)))
+ (multiple-value-bind (match groups)
+ (cl-ppcre:scan-to-strings pattern xml)
+ (when match
+ (aref groups 0)))))
+
+(defun extract-xml-sources (xml)
+ "Extract all source blocks from Icecast XML"
+ (let ((sources nil)
+ (pattern "(.*?)"))
+ (cl-ppcre:do-register-groups (mount content) (pattern xml)
+ (let ((listeners (extract-xml-value content "listeners"))
+ (listener-peak (extract-xml-value content "listener_peak"))
+ (server-name (extract-xml-value content "server_name")))
+ (push (list :mount mount
+ :server-name server-name
+ :listeners (if listeners (parse-integer listeners :junk-allowed t) 0)
+ :listener-peak (if listener-peak (parse-integer listener-peak :junk-allowed t) 0))
+ sources)))
+ (nreverse sources)))
+
+(defun fetch-icecast-stats ()
+ "Fetch current statistics from Icecast admin XML endpoint"
+ (handler-case
+ (let ((response (drakma:http-request *icecast-stats-url*
+ :want-stream nil
+ :connection-timeout 5
+ :basic-authorization (list *icecast-admin-user*
+ *icecast-admin-pass*))))
+ ;; Response is XML, return as string for parsing
+ (if (stringp response)
+ response
+ (babel:octets-to-string response :encoding :utf-8)))
+ (error (e)
+ (log:warn "Failed to fetch Icecast stats: ~a" e)
+ nil)))
+
+(defun parse-icecast-sources (xml-string)
+ "Parse Icecast XML stats and extract source/mount information.
+ Returns list of plists with mount info."
+ (when xml-string
+ (extract-xml-sources xml-string)))
+
+;;; Database Operations
+
+(defun store-listener-snapshot (mount listener-count)
+ "Store a listener count snapshot"
+ (handler-case
+ (with-db
+ (postmodern:query
+ (:insert-into 'listener_snapshots :set 'mount mount 'listener_count listener-count)))
+ (error (e)
+ (log:error "Failed to store snapshot: ~a" e))))
+
+(defun store-listener-session (session-id ip-hash mount &key country-code city region user-agent user-id)
+ "Create a new listener session record"
+ (handler-case
+ (with-db
+ (postmodern:query
+ (:insert-into 'listener_sessions
+ :set 'session_id session-id
+ 'ip_hash ip-hash
+ 'mount mount
+ 'country_code country-code
+ 'city city
+ 'region region
+ 'user_agent user-agent
+ 'user_id user-id)))
+ (error (e)
+ (log:error "Failed to store session: ~a" e))))
+
+(defun end-listener-session (session-id)
+ "Mark a listener session as ended and calculate duration"
+ (handler-case
+ (with-db
+ (postmodern:execute
+ (format nil "UPDATE listener_sessions
+ SET session_end = NOW(),
+ duration_seconds = EXTRACT(EPOCH FROM (NOW() - session_start))::INTEGER
+ WHERE session_id = '~a' AND session_end IS NULL" session-id)))
+ (error (e)
+ (log:error "Failed to end session: ~a" e))))
+
+(defun cleanup-old-sessions ()
+ "Remove session data older than retention period (GDPR compliance)"
+ (handler-case
+ (with-db
+ (let ((result (postmodern:query
+ (format nil "SELECT cleanup_old_listener_data(~a)" *session-retention-days*))))
+ (log:info "Session cleanup completed: ~a records removed" (caar result))))
+ (error (e)
+ (log:error "Session cleanup failed: ~a" e))))
+
+;;; Statistics Aggregation
+;;; Note: Complex aggregation queries use raw SQL via postmodern:execute
+
+(defun aggregate-daily-stats (date)
+ "Compute daily aggregates from session data"
+ (handler-case
+ (with-db
+ (postmodern:execute
+ (format nil "INSERT INTO listener_daily_stats
+ (date, mount, unique_listeners, peak_concurrent, total_listen_minutes, avg_session_minutes)
+ SELECT
+ '~a'::date,
+ mount,
+ COUNT(DISTINCT ip_hash),
+ (SELECT COALESCE(MAX(listener_count), 0) FROM listener_snapshots
+ WHERE timestamp::date = '~a'::date AND listener_snapshots.mount = listener_sessions.mount),
+ COALESCE(SUM(duration_seconds) / 60, 0),
+ COALESCE(AVG(duration_seconds) / 60.0, 0)
+ FROM listener_sessions
+ WHERE session_start::date = '~a'::date
+ GROUP BY mount
+ ON CONFLICT (date, mount) DO UPDATE SET
+ unique_listeners = EXCLUDED.unique_listeners,
+ peak_concurrent = EXCLUDED.peak_concurrent,
+ total_listen_minutes = EXCLUDED.total_listen_minutes,
+ avg_session_minutes = EXCLUDED.avg_session_minutes" date date date)))
+ (error (e)
+ (log:error "Failed to aggregate daily stats: ~a" e))))
+
+(defun aggregate-hourly-stats (date hour)
+ "Compute hourly aggregates"
+ (handler-case
+ (with-db
+ (postmodern:execute
+ (format nil "INSERT INTO listener_hourly_stats (date, hour, mount, unique_listeners, peak_concurrent)
+ SELECT
+ '~a'::date,
+ ~a,
+ mount,
+ COUNT(DISTINCT ip_hash),
+ COALESCE(MAX(listener_count), 0)
+ FROM listener_sessions ls
+ LEFT JOIN listener_snapshots lsn ON lsn.mount = ls.mount
+ AND DATE_TRUNC('hour', lsn.timestamp) = DATE_TRUNC('hour', ls.session_start)
+ WHERE session_start::date = '~a'::date
+ AND EXTRACT(HOUR FROM session_start) = ~a
+ GROUP BY mount
+ ON CONFLICT (date, hour, mount) DO UPDATE SET
+ unique_listeners = EXCLUDED.unique_listeners,
+ peak_concurrent = EXCLUDED.peak_concurrent" date hour date hour)))
+ (error (e)
+ (log:error "Failed to aggregate hourly stats: ~a" e))))
+
+;;; Query Functions (for API endpoints)
+
+(defun get-current-listeners ()
+ "Get current listener count from most recent snapshot"
+ (handler-case
+ (with-db
+ (let ((result (postmodern:query
+ "SELECT mount, listener_count, timestamp
+ FROM listener_snapshots
+ WHERE timestamp > NOW() - INTERVAL '5 minutes'
+ ORDER BY timestamp DESC")))
+ (mapcar (lambda (row)
+ (list :mount (first row)
+ :listeners (second row)
+ :timestamp (third row)))
+ result)))
+ (error (e)
+ (log:error "Failed to get current listeners: ~a" e)
+ nil)))
+
+(defun get-daily-stats (&optional (days 30))
+ "Get daily statistics for the last N days"
+ (handler-case
+ (with-db
+ (postmodern:query
+ (format nil "SELECT date, mount, unique_listeners, peak_concurrent, total_listen_minutes, avg_session_minutes
+ FROM listener_daily_stats
+ WHERE date > NOW() - INTERVAL '~a days'
+ ORDER BY date DESC" days)))
+ (error (e)
+ (log:error "Failed to get daily stats: ~a" e)
+ nil)))
+
+(defun get-geo-stats (&optional (days 7))
+ "Get geographic distribution for the last N days"
+ (handler-case
+ (with-db
+ (postmodern:query
+ (format nil "SELECT country_code, SUM(listener_count) as total_listeners, SUM(listen_minutes) as total_minutes
+ FROM listener_geo_stats
+ WHERE date > NOW() - INTERVAL '~a days'
+ GROUP BY country_code
+ ORDER BY total_listeners DESC
+ LIMIT 20" days)))
+ (error (e)
+ (log:error "Failed to get geo stats: ~a" e)
+ nil)))
+
+(defun get-user-listening-stats (user-id)
+ "Get listening statistics for a specific user"
+ (handler-case
+ (with-db
+ (let ((total-time (caar (postmodern:query
+ (format nil "SELECT COALESCE(SUM(duration_seconds), 0)
+ FROM listener_sessions WHERE user_id = ~a" user-id))))
+ (session-count (caar (postmodern:query
+ (format nil "SELECT COUNT(*) FROM listener_sessions WHERE user_id = ~a" user-id))))
+ (track-count (caar (postmodern:query
+ (format nil "SELECT COUNT(*) FROM user_listening_history WHERE user_id = ~a" user-id)))))
+ (list :total-listen-time (or total-time 0)
+ :session-count (or session-count 0)
+ :tracks-played (or track-count 0))))
+ (error (e)
+ (log:error "Failed to get user stats: ~a" e)
+ (list :total-listen-time 0 :session-count 0 :tracks-played 0))))
+
+;;; Polling Service
+
+(defun poll-and-store-stats ()
+ "Single poll iteration: fetch stats and store"
+ (let ((stats (fetch-icecast-stats)))
+ (when stats
+ (let ((sources (parse-icecast-sources stats)))
+ (dolist (source sources)
+ (let ((mount (getf source :mount))
+ (listeners (getf source :listeners)))
+ (when mount
+ (store-listener-snapshot mount listeners)
+ (log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))))))
+
+(defun stats-polling-loop ()
+ "Main polling loop - runs in background thread"
+ (log:info "Listener statistics polling started (interval: ~as)" *stats-polling-interval*)
+ (loop while *stats-polling-active*
+ do (handler-case
+ (poll-and-store-stats)
+ (error (e)
+ (log:error "Polling error: ~a" e)))
+ (sleep *stats-polling-interval*))
+ (log:info "Listener statistics polling stopped"))
+
+(defun start-stats-polling ()
+ "Start the background statistics polling thread"
+ (when *stats-polling-thread*
+ (stop-stats-polling))
+ (setf *stats-polling-active* t)
+ (setf *stats-polling-thread*
+ (bt:make-thread #'stats-polling-loop :name "stats-poller"))
+ (log:info "Stats polling thread started"))
+
+(defun stop-stats-polling ()
+ "Stop the background statistics polling thread"
+ (setf *stats-polling-active* nil)
+ (when (and *stats-polling-thread* (bt:thread-alive-p *stats-polling-thread*))
+ (bt:join-thread *stats-polling-thread* :timeout 5))
+ (setf *stats-polling-thread* nil)
+ (log:info "Stats polling thread stopped"))
+
+;;; Initialization
+
+(defun init-listener-stats ()
+ "Initialize the listener statistics system"
+ (log:info "Initializing listener statistics system...")
+ (start-stats-polling))
+
+(defun shutdown-listener-stats ()
+ "Shutdown the listener statistics system"
+ (log:info "Shutting down listener statistics system...")
+ (stop-stats-polling))
diff --git a/migrations/002-listener-statistics.sql b/migrations/002-listener-statistics.sql
new file mode 100644
index 0000000..d03ef9b
--- /dev/null
+++ b/migrations/002-listener-statistics.sql
@@ -0,0 +1,127 @@
+-- Migration: Listener Statistics Tables
+-- Version: 002
+-- Date: 2025-12-08
+-- Description: Add tables for tracking listener statistics with GDPR compliance
+
+-- Listener snapshots: periodic counts from Icecast polling
+CREATE TABLE IF NOT EXISTS listener_snapshots (
+ _id SERIAL PRIMARY KEY,
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ mount VARCHAR(100) NOT NULL,
+ listener_count INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON listener_snapshots(timestamp);
+CREATE INDEX IF NOT EXISTS idx_snapshots_mount ON listener_snapshots(mount);
+
+-- Listener sessions: individual connection records (privacy-safe)
+CREATE TABLE IF NOT EXISTS listener_sessions (
+ _id SERIAL PRIMARY KEY,
+ session_id VARCHAR(64) UNIQUE NOT NULL,
+ session_start TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ session_end TIMESTAMP,
+ ip_hash VARCHAR(64) NOT NULL, -- SHA256 hash, not reversible
+ country_code VARCHAR(2),
+ city VARCHAR(100),
+ region VARCHAR(100),
+ user_agent TEXT,
+ mount VARCHAR(100) NOT NULL,
+ duration_seconds INTEGER,
+ user_id INTEGER REFERENCES "USERS"(_id) ON DELETE SET NULL -- Optional link to registered user
+);
+
+CREATE INDEX IF NOT EXISTS idx_sessions_start ON listener_sessions(session_start);
+CREATE INDEX IF NOT EXISTS idx_sessions_ip_hash ON listener_sessions(ip_hash);
+CREATE INDEX IF NOT EXISTS idx_sessions_country ON listener_sessions(country_code);
+CREATE INDEX IF NOT EXISTS idx_sessions_user ON listener_sessions(user_id);
+
+-- Daily aggregated statistics (for efficient dashboard queries)
+CREATE TABLE IF NOT EXISTS listener_daily_stats (
+ _id SERIAL PRIMARY KEY,
+ date DATE NOT NULL,
+ mount VARCHAR(100) NOT NULL,
+ unique_listeners INTEGER DEFAULT 0,
+ peak_concurrent INTEGER DEFAULT 0,
+ total_listen_minutes INTEGER DEFAULT 0,
+ new_listeners INTEGER DEFAULT 0,
+ returning_listeners INTEGER DEFAULT 0,
+ avg_session_minutes DECIMAL(10,2),
+ UNIQUE(date, mount)
+);
+
+CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON listener_daily_stats(date);
+
+-- Hourly breakdown for time-of-day analysis
+CREATE TABLE IF NOT EXISTS listener_hourly_stats (
+ _id SERIAL PRIMARY KEY,
+ date DATE NOT NULL,
+ hour INTEGER NOT NULL CHECK (hour >= 0 AND hour <= 23),
+ mount VARCHAR(100) NOT NULL,
+ unique_listeners INTEGER DEFAULT 0,
+ peak_concurrent INTEGER DEFAULT 0,
+ UNIQUE(date, hour, mount)
+);
+
+CREATE INDEX IF NOT EXISTS idx_hourly_stats_date ON listener_hourly_stats(date);
+
+-- Geographic aggregates
+CREATE TABLE IF NOT EXISTS listener_geo_stats (
+ _id SERIAL PRIMARY KEY,
+ date DATE NOT NULL,
+ country_code VARCHAR(2) NOT NULL,
+ city VARCHAR(100),
+ listener_count INTEGER DEFAULT 0,
+ listen_minutes INTEGER DEFAULT 0,
+ 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_country ON listener_geo_stats(country_code);
+
+-- User listening history (for registered users only)
+CREATE TABLE IF NOT EXISTS user_listening_history (
+ _id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
+ track_title VARCHAR(500),
+ track_artist VARCHAR(500),
+ listened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ duration_seconds INTEGER
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_history_user ON user_listening_history(user_id);
+CREATE INDEX IF NOT EXISTS idx_user_history_listened ON user_listening_history(listened_at);
+
+-- Data retention: function to clean old session data (GDPR compliance)
+CREATE OR REPLACE FUNCTION cleanup_old_listener_data(retention_days INTEGER DEFAULT 30)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted_count INTEGER;
+BEGIN
+ -- Delete individual sessions older than retention period
+ DELETE FROM listener_sessions
+ WHERE session_start < NOW() - (retention_days || ' days')::INTERVAL;
+
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
+
+ RAISE NOTICE 'Cleaned up % listener session records older than % days', deleted_count, retention_days;
+
+ RETURN deleted_count;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Grant permissions
+GRANT ALL PRIVILEGES ON listener_snapshots TO asteroid;
+GRANT ALL PRIVILEGES ON listener_sessions TO asteroid;
+GRANT ALL PRIVILEGES ON listener_daily_stats TO asteroid;
+GRANT ALL PRIVILEGES ON listener_hourly_stats TO asteroid;
+GRANT ALL PRIVILEGES ON listener_geo_stats TO asteroid;
+GRANT ALL PRIVILEGES ON user_listening_history TO asteroid;
+GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO asteroid;
+
+-- Success message
+DO $$
+BEGIN
+ RAISE NOTICE 'Listener statistics tables created successfully!';
+ RAISE NOTICE 'Tables: listener_snapshots, listener_sessions, listener_daily_stats, listener_hourly_stats, listener_geo_stats, user_listening_history';
+ RAISE NOTICE 'GDPR: IP addresses are hashed, cleanup function available';
+END $$;
diff --git a/static/asteroid.css b/static/asteroid.css
index 2b7e936..f1b4190 100644
--- a/static/asteroid.css
+++ b/static/asteroid.css
@@ -1224,6 +1224,12 @@ body .stat-card .stat-label{
margin-top: 0.5rem;
}
+body .stat-card .stat-detail{
+ color: #888;
+ font-size: 0.75rem;
+ margin-top: 0.25rem;
+}
+
body.persistent-player-container{
margin: 0;
padding: 10px;;
diff --git a/static/asteroid.lass b/static/asteroid.lass
index 189319e..582943c 100644
--- a/static/asteroid.lass
+++ b/static/asteroid.lass
@@ -978,7 +978,11 @@
(.stat-label :color "#ccc"
:font-size 0.875rem
- :margin-top 0.5rem))
+ :margin-top 0.5rem)
+
+ (.stat-detail :color "#888"
+ :font-size 0.75rem
+ :margin-top 0.25rem))
;; Center alignment for player page
;; (body.player-page
diff --git a/template/admin.ctml b/template/admin.ctml
index 8c655db..345e40f 100644
--- a/template/admin.ctml
+++ b/template/admin.ctml
@@ -42,6 +42,41 @@
+
+
+
📊 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: --
+
+
+
+
+
+
+
+
Music Library Management
@@ -192,6 +227,60 @@