/ template / admin.ctml
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>