admin.ctml
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <title data-text="title">Asteroid Radio - Admin Dashboard</title> 5 <meta charset="utf-8"> 6 <meta name="viewport" content="width=device-width, initial-scale=1"> 7 <link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css"> 8 <script src="/asteroid/static/js/auth-ui.js"></script> 9 <script src="/asteroid/static/js/admin.js"></script> 10 </head> 11 <body> 12 <div class="container"> 13 <h1>๐๏ธ ADMIN DASHBOARD</h1> 14 <div class="nav"> 15 <a href="/asteroid">Home</a> 16 <a href="/asteroid/player">Player</a> 17 <a href="/asteroid/profile">Profile</a> 18 <a href="/asteroid/admin/users">๐ฅ Users</a> 19 <a href="/asteroid/logout" class="btn-logout">Logout</a> 20 </div> 21 22 <!-- System Status --> 23 <div class="admin-section"> 24 <h2>System Status</h2> 25 <div class="admin-grid"> 26 <div class="status-card"> 27 <h3>Server Status</h3> 28 <p class="status-good" data-text="server-status">๐ข Running</p> 29 </div> 30 <div class="status-card"> 31 <h3>Database Status</h3> 32 <p class="status-good" data-text="database-status">๐ข Connected</p> 33 </div> 34 <div class="status-card"> 35 <h3>Liquidsoap Status</h3> 36 <p class="status-error" data-text="liquidsoap-status">๐ด Not Running</p> 37 </div> 38 <div class="status-card"> 39 <h3>Icecast Status</h3> 40 <p class="status-error" data-text="icecast-status">๐ด Not Running</p> 41 <button id="icecast-restart" class="btn btn-danger btn-sm" style="margin-top: 8px;">๐ Restart</button> 42 </div> 43 </div> 44 </div> 45 46 <!-- Listener Statistics --> 47 <div class="admin-section"> 48 <h2>๐ Current Listeners</h2> 49 <table class="listener-stats-table" style="table-layout: fixed; width: 100%;"> 50 <colgroup> 51 <col style="width: 25%;"> 52 <col style="width: 25%;"> 53 <col style="width: 25%;"> 54 <col style="width: 25%;"> 55 </colgroup> 56 <thead> 57 <tr> 58 <th>๐ต MP3</th> 59 <th>๐ง AAC</th> 60 <th>๐ฑ Low</th> 61 <th>๐ Total</th> 62 </tr> 63 </thead> 64 <tbody> 65 <tr> 66 <td style="text-align: center;"><span class="stat-number" id="listeners-mp3">0</span></td> 67 <td style="text-align: center;"><span class="stat-number" id="listeners-aac">0</span></td> 68 <td style="text-align: center;"><span class="stat-number" id="listeners-low">0</span></td> 69 <td style="text-align: center;"><span class="stat-number" id="listeners-total">0</span></td> 70 </tr> 71 <tr class="stat-peak-row"> 72 <td style="text-align: center;">Peak: <span id="peak-mp3">0</span></td> 73 <td style="text-align: center;">Peak: <span id="peak-aac">0</span></td> 74 <td style="text-align: center;">Peak: <span id="peak-low">0</span></td> 75 <td style="text-align: center;">Updated: <span id="stats-updated">--</span></td> 76 </tr> 77 </tbody> 78 </table> 79 <div class="admin-controls" style="margin-top: 10px;"> 80 <button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">๐ Refresh</button> 81 <span id="stats-status" style="margin-left: 15px;"></span> 82 </div> 83 84 <!-- Geo Stats --> 85 <h3 style="margin-top: 20px;">๐ Listener Locations (Last 7 Days)</h3> 86 <div id="geo-stats-container"> 87 <table class="listener-stats-table" id="geo-stats-table"> 88 <thead> 89 <tr> 90 <th>Country</th> 91 <th>Listeners</th> 92 <th>Minutes</th> 93 </tr> 94 </thead> 95 <tbody id="geo-stats-body"> 96 <tr><td colspan="3" style="color: #888;">Loading...</td></tr> 97 </tbody> 98 </table> 99 </div> 100 </div> 101 102 <!-- Music Library Management --> 103 <div class="admin-section"> 104 <h2>Music Library Management</h2> 105 106 <!-- Music Library Info --> 107 <div class="upload-section"> 108 <h3>Music Library</h3> 109 <div class="upload-info"> 110 <p>The music library is mounted from your local filesystem into the Liquidsoap container.</p> 111 <p><strong>To add music:</strong></p> 112 <ol> 113 <li>Add files to your music library directory (set via <code>MUSIC_LIBRARY</code> env var)</li> 114 <li>Click "Scan Library" to index new tracks into the database</li> 115 </ol> 116 <p><em>Supported formats: MP3, FLAC, OGG, WAV, OPUS</em></p> 117 </div> 118 </div> 119 120 <div class="admin-controls"> 121 <button id="scan-library" class="btn btn-primary">๐ Scan Library</button> 122 <button id="refresh-tracks" class="btn btn-secondary">๐ Refresh Track List</button> 123 <span id="scan-status" style="margin-left: 15px; font-weight: bold;"></span> 124 </div> 125 126 <div class="track-stats"> 127 <p>Total Tracks: <span id="track-count" data-text="track-count">0</span></p> 128 </div> 129 </div> 130 131 <!-- Track Management --> 132 <div class="admin-section"> 133 <h2>Track Management</h2> 134 <div class="track-controls"> 135 <input type="text" id="track-search" placeholder="Search tracks..." class="search-input"> 136 <select id="sort-tracks" class="sort-select"> 137 <option value="title">Sort by Title</option> 138 <option value="artist">Sort by Artist</option> 139 <option value="album">Sort by Album</option> 140 </select> 141 <select id="tracks-per-page" class="sort-select" onchange="changeTracksPerPage()"> 142 <option value="10">10 per page</option> 143 <option value="20" selected>20 per page</option> 144 <option value="50">50 per page</option> 145 <option value="100">100 per page</option> 146 </select> 147 </div> 148 149 <div id="tracks-container" class="tracks-list"> 150 <div class="loading">Loading tracks...</div> 151 </div> 152 153 <!-- Pagination Controls --> 154 <div id="pagination-controls" style="display: none; margin-top: 20px; text-align: center;"> 155 <button onclick="goToPage(1)" class="btn btn-secondary">ยซ First</button> 156 <button onclick="previousPage()" class="btn btn-secondary">โน Prev</button> 157 <span id="page-info" style="margin: 0 15px; font-weight: bold;">Page 1 of 1</span> 158 <button onclick="nextPage()" class="btn btn-secondary">Next โบ</button> 159 <button onclick="goToLastPage()" class="btn btn-secondary">Last ยป</button> 160 </div> 161 </div> 162 163 <!-- Stream Queue Management --> 164 <div class="admin-section"> 165 <h2>๐ต Stream Queue Management</h2> 166 <p>Manage the live stream playback queue. Liquidsoap watches <code>stream-queue.m3u</code> and reloads automatically.</p> 167 168 <!-- Playlist Selection --> 169 <div class="playlist-controls" style="margin-bottom: 20px; padding: 15px; background: #2a2a2a; border-radius: 8px;"> 170 <h3 style="margin-top: 0;">๐ Load Playlist</h3> 171 <div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;"> 172 <select id="playlist-select" class="sort-select" style="min-width: 250px;"> 173 <option value="">-- Select a playlist --</option> 174 </select> 175 <button id="load-playlist-btn" class="btn btn-success">๐ Load Selected</button> 176 <button id="refresh-playlists-btn" class="btn btn-secondary">๐ Refresh List</button> 177 </div> 178 <p style="margin: 10px 0 0 0; font-size: 0.9em; color: #888;"> 179 Loading a playlist will copy it to <code>stream-queue.m3u</code> and Liquidsoap will start playing it. 180 </p> 181 </div> 182 183 <!-- Queue Controls --> 184 <div class="queue-controls" style="margin-bottom: 15px;"> 185 <button id="refresh-queue" class="btn btn-secondary">๐ Refresh Queue</button> 186 <button id="save-queue-btn" class="btn btn-primary">๐พ Save Queue</button> 187 <button id="clear-queue-btn" class="btn btn-warning">๐๏ธ Clear Queue</button> 188 <button id="add-random-tracks" class="btn btn-info">๐ฒ Add 10 Random</button> 189 </div> 190 191 <!-- Save As --> 192 <div style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center;"> 193 <input type="text" id="save-as-name" placeholder="New playlist name..." class="search-input" style="max-width: 250px;"> 194 <button id="save-as-btn" class="btn btn-success">๐พ Save As New Playlist</button> 195 </div> 196 197 <!-- Queue Status --> 198 <div id="queue-status" style="margin-bottom: 15px; padding: 10px; background: #1a1a1a; border-radius: 4px;"> 199 <span id="queue-count">0</span> tracks in queue 200 </div> 201 202 <!-- Queue Contents --> 203 <div id="stream-queue-container" class="queue-list" style="max-height: 400px; overflow-y: auto;"> 204 <div class="loading">Loading queue...</div> 205 </div> 206 207 <div class="queue-actions" style="margin-top: 20px;"> 208 <h3>Add Tracks to Queue</h3> 209 <input type="text" id="queue-track-search" placeholder="Search tracks to add..." class="search-input"> 210 <div id="queue-track-results" class="track-results"></div> 211 </div> 212 </div> 213 214 <!-- Liquidsoap Stream Control --> 215 <div class="admin-section"> 216 <h2>๐ก Stream Control (Liquidsoap)</h2> 217 <p>Control the live audio stream. Commands are sent directly to Liquidsoap.</p> 218 219 <!-- Status Display --> 220 <div id="liquidsoap-status" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;"> 221 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;"> 222 <div> 223 <strong>Uptime:</strong> <span id="ls-uptime">--</span> 224 </div> 225 <div> 226 <strong>Remaining:</strong> <span id="ls-remaining">--</span> 227 </div> 228 </div> 229 <div style="margin-top: 10px;"> 230 <strong>Now Playing:</strong> <span id="ls-metadata">--</span> 231 </div> 232 </div> 233 234 <!-- Control Buttons --> 235 <div class="queue-controls" style="margin-bottom: 15px;"> 236 <button id="ls-refresh-status" class="btn btn-secondary">๐ Refresh Status</button> 237 <button id="ls-skip" class="btn btn-warning">โญ๏ธ Skip Track</button> 238 <button id="ls-reload" class="btn btn-info">๐ Reload Playlist</button> 239 <button id="ls-restart" class="btn btn-danger">๐ Restart Container</button> 240 </div> 241 242 <p style="font-size: 0.9em; color: #888;"> 243 <strong>Skip Track:</strong> Immediately skip to the next track in the playlist.<br> 244 <strong>Reload Playlist:</strong> Force Liquidsoap to re-read stream-queue.m3u.<br> 245 <strong>Restart Container:</strong> Restart the Liquidsoap Docker container (causes brief stream interruption). 246 </p> 247 </div> 248 249 <!-- User Management --> 250 <div class="admin-section"> 251 <div class="card"> 252 <h3>๐ฅ User Management</h3> 253 <p>Manage user accounts, roles, and permissions.</p> 254 <div class="controls"> 255 <a href="/asteroid/admin/users" class="btn btn-primary">๐ฅ Manage Users</a> 256 </div> 257 258 <!-- Admin Password Reset Form --> 259 <div class="form-section" style="margin-top: 20px;"> 260 <h4>๐ Reset User Password</h4> 261 <form id="admin-reset-password-form" onsubmit="return resetUserPassword(event)"> 262 <div class="form-group"> 263 <label for="reset-username">Username:</label> 264 <input type="text" id="reset-username" name="username" required> 265 </div> 266 <div class="form-group"> 267 <label for="reset-new-password">New Password:</label> 268 <input type="password" id="reset-new-password" name="new-password" required minlength="8"> 269 </div> 270 <div class="form-group"> 271 <label for="reset-confirm-password">Confirm Password:</label> 272 <input type="password" id="reset-confirm-password" name="confirm-password" required minlength="8"> 273 </div> 274 <div id="reset-password-message" class="message"></div> 275 <button type="submit" class="btn btn-primary">Reset Password</button> 276 </form> 277 </div> 278 </div> 279 </div> 280 </div> 281 282 <script> 283 // Listener Statistics 284 function refreshListenerStats() { 285 const statusEl = document.getElementById('stats-status'); 286 statusEl.textContent = 'Loading...'; 287 288 fetch('/api/asteroid/stats/current') 289 .then(response => response.json()) 290 .then(result => { 291 const data = result.data || result; 292 if (data.status === 'success' && data.listeners) { 293 // Process listener data - get most recent for each mount 294 const mounts = {}; 295 data.listeners.forEach(item => { 296 // item is [mount, "/asteroid.mp3", listeners, 1, timestamp, 123456] 297 const mount = item[1]; 298 const listeners = item[3]; 299 if (!mounts[mount] || item[5] > mounts[mount].timestamp) { 300 mounts[mount] = { listeners: listeners, timestamp: item[5] }; 301 } 302 }); 303 304 // Update UI 305 const mp3 = mounts['/asteroid.mp3']?.listeners || 0; 306 const aac = mounts['/asteroid.aac']?.listeners || 0; 307 const low = mounts['/asteroid-low.mp3']?.listeners || 0; 308 309 document.getElementById('listeners-mp3').textContent = mp3; 310 document.getElementById('listeners-aac').textContent = aac; 311 document.getElementById('listeners-low').textContent = low; 312 document.getElementById('listeners-total').textContent = mp3 + aac + low; 313 314 const now = new Date(); 315 document.getElementById('stats-updated').textContent = 316 now.toLocaleTimeString(); 317 318 statusEl.textContent = ''; 319 } else { 320 statusEl.textContent = 'No data available'; 321 } 322 }) 323 .catch(error => { 324 console.error('Error fetching stats:', error); 325 statusEl.textContent = 'Error loading stats'; 326 }); 327 } 328 329 // Country code to flag emoji 330 function countryToFlag(countryCode) { 331 if (!countryCode || countryCode.length !== 2) return '๐'; 332 const codePoints = countryCode 333 .toUpperCase() 334 .split('') 335 .map(char => 127397 + char.charCodeAt(0)); 336 return String.fromCodePoint(...codePoints); 337 } 338 339 // Fetch and display geo stats 340 function refreshGeoStats() { 341 fetch('/api/asteroid/stats/geo?days=7') 342 .then(response => response.json()) 343 .then(result => { 344 const data = result.data || result; 345 const tbody = document.getElementById('geo-stats-body'); 346 347 if (data.status === 'success' && data.geo && data.geo.length > 0) { 348 tbody.innerHTML = data.geo.map(item => { 349 const country = item.country_code || item[0]; 350 const listeners = item.total_listeners || item[1] || 0; 351 const minutes = item.total_minutes || item[2] || 0; 352 return `<tr> 353 <td>${countryToFlag(country)} ${country}</td> 354 <td>${listeners}</td> 355 <td>${minutes}</td> 356 </tr>`; 357 }).join(''); 358 } else { 359 tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>'; 360 } 361 }) 362 .catch(error => { 363 console.error('Error fetching geo stats:', error); 364 document.getElementById('geo-stats-body').innerHTML = 365 '<tr><td colspan="3" style="color: #ff6666;">Error loading geo data</td></tr>'; 366 }); 367 } 368 369 // Auto-refresh stats every 30 seconds 370 setInterval(refreshListenerStats, 30000); 371 setInterval(refreshGeoStats, 60000); 372 373 // Initial load 374 document.addEventListener('DOMContentLoaded', function() { 375 refreshListenerStats(); 376 refreshGeoStats(); 377 }); 378 379 // Admin password reset handler 380 function resetUserPassword(event) { 381 event.preventDefault(); 382 383 const username = document.getElementById('reset-username').value; 384 const newPassword = document.getElementById('reset-new-password').value; 385 const confirmPassword = document.getElementById('reset-confirm-password').value; 386 const messageDiv = document.getElementById('reset-password-message'); 387 388 // Client-side validation 389 if (newPassword.length < 8) { 390 messageDiv.textContent = 'New password must be at least 8 characters'; 391 messageDiv.className = 'message error'; 392 return false; 393 } 394 395 if (newPassword !== confirmPassword) { 396 messageDiv.textContent = 'Passwords do not match'; 397 messageDiv.className = 'message error'; 398 return false; 399 } 400 401 // Send request to API 402 const formData = new FormData(); 403 formData.append('username', username); 404 formData.append('new-password', newPassword); 405 406 fetch('/api/asteroid/admin/reset-password', { 407 method: 'POST', 408 body: formData 409 }) 410 .then(response => response.json()) 411 .then(data => { 412 if (data.status === 'success' || (data.data && data.data.status === 'success')) { 413 messageDiv.textContent = 'Password reset successfully for user: ' + username; 414 messageDiv.className = 'message success'; 415 document.getElementById('admin-reset-password-form').reset(); 416 } else { 417 messageDiv.textContent = data.message || data.data?.message || 'Failed to reset password'; 418 messageDiv.className = 'message error'; 419 } 420 }) 421 .catch(error => { 422 console.error('Error resetting password:', error); 423 messageDiv.textContent = 'Error resetting password'; 424 messageDiv.className = 'message error'; 425 }); 426 427 return false; 428 } 429 </script> 430 </body> 431 </html>