Compare commits

..

2 Commits

Author SHA1 Message Date
glenneth c129959eaf feat: Add database persistence and editable UI for playlist schedule
- Store schedule in PostgreSQL (playlist_schedule table)
- Load schedule from database on startup
- Admin UI: add/update schedule entries with hour and playlist dropdowns
- Admin UI: delete buttons for each schedule entry
- Available playlists populated from playlists directory
- Changes persist across server restarts
2025-12-17 16:01:46 +03:00
glenneth 16b537a51d fix: Load current scheduled playlist on startup
The scheduler now loads the appropriate playlist immediately when the
server starts, not just at the next scheduled time. This ensures the
stream plays the correct time-based playlist right away.
2025-12-17 15:31:37 +03:00
4 changed files with 202 additions and 14 deletions

View File

@ -0,0 +1,27 @@
-- Migration 004: Playlist Schedule Table
-- Description: Store playlist schedule configuration in database for persistence
CREATE TABLE IF NOT EXISTS playlist_schedule (
_id SERIAL PRIMARY KEY,
hour INTEGER NOT NULL UNIQUE CHECK (hour >= 0 AND hour <= 23),
playlist VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert default schedule
INSERT INTO playlist_schedule (hour, playlist) VALUES
(0, 'midnight-ambient.m3u'),
(6, 'morning-drift.m3u'),
(12, 'afternoon-orbit.m3u'),
(18, 'evening-descent.m3u')
ON CONFLICT (hour) DO NOTHING;
-- Grant permissions
GRANT ALL PRIVILEGES ON playlist_schedule TO asteroid;
GRANT ALL PRIVILEGES ON playlist_schedule__id_seq TO asteroid;
DO $$
BEGIN
RAISE NOTICE 'Playlist schedule table created successfully!';
END $$;

View File

@ -1158,7 +1158,17 @@
(setf (ps:@ status-el inner-h-t-m-l)
"<span style=\"color: #ffaa00;\">🟡 Disabled</span>"))))
;; Update schedule table
;; Update available playlists dropdown
(let ((playlist-select (ps:chain document (get-element-by-id "schedule-playlist")))
(available (ps:@ data available_playlists)))
(when (and playlist-select available)
(let ((html "<option value=\"\">-- Select Playlist --</option>"))
(ps:chain available
(for-each (lambda (p)
(setf html (+ html "<option value=\"" p "\">" p "</option>")))))
(setf (ps:@ playlist-select inner-h-t-m-l) html))))
;; Update schedule table with edit/delete buttons
(let ((table-body (ps:chain document (get-element-by-id "scheduler-table-body")))
(schedule (ps:@ data schedule))
(current-hour (when (ps:@ data server_time) (ps:@ data server_time utc_hour))))
@ -1168,21 +1178,65 @@
(for-each (lambda (entry)
(let* ((hour (ps:@ entry hour))
(playlist (ps:@ entry playlist))
(is-active (and current-hour
(>= current-hour hour)
(or (not (ps:chain schedule (find (lambda (e) (and (> (ps:@ e hour) hour) (<= (ps:@ e hour) current-hour))))))
t))))
(is-active (= playlist (ps:@ data current_playlist))))
(setf html
(+ html
"<tr" (if (= playlist (ps:@ data current_playlist)) " style=\"background: #1a3a1a;\"" "") ">"
"<tr" (if is-active " style=\"background: #1a3a1a;\"" "") ">"
"<td>" (if (< hour 10) "0" "") hour ":00 UTC</td>"
"<td>" playlist "</td>"
"<td>" (if (= playlist (ps:@ data current_playlist)) "▶️ Active" "") "</td>"
"<td>" (if is-active "▶️ Active" "") "</td>"
"<td><button class=\"btn btn-danger btn-sm\" onclick=\"removeScheduleEntry(" hour ")\">🗑️</button></td>"
"</tr>"))))))
(setf (ps:@ table-body inner-h-t-m-l) html))))))))
(catch (lambda (error)
(ps:chain console (error "Error loading scheduler status:" error))))))
;; Add or update schedule entry
(defun add-schedule-entry ()
(let ((hour-select (ps:chain document (get-element-by-id "schedule-hour")))
(playlist-select (ps:chain document (get-element-by-id "schedule-playlist"))))
(when (and hour-select playlist-select)
(let ((hour (parse-int (ps:@ hour-select value)))
(playlist (ps:@ playlist-select value)))
(if (= playlist "")
(alert "Please select a playlist")
(ps:chain
(fetch "/api/asteroid/scheduler/update"
(ps:create :method "POST"
:headers (ps:create "Content-Type" "application/x-www-form-urlencoded")
:body (+ "hour=" hour "&playlist=" (encode-u-r-i-component playlist))))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-toast (+ "✓ Schedule updated: " hour ":00 → " playlist))
(refresh-scheduler-status))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error updating schedule:" error))
(alert "Error updating schedule")))))))))
;; Remove schedule entry
(defun remove-schedule-entry (hour)
(when (confirm (+ "Remove schedule entry for " (if (< hour 10) "0" "") hour ":00 UTC?"))
(ps:chain
(fetch "/api/asteroid/scheduler/remove"
(ps:create :method "POST"
:headers (ps:create "Content-Type" "application/x-www-form-urlencoded")
:body (+ "hour=" hour)))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-toast (+ "✓ Removed schedule entry for " hour ":00"))
(refresh-scheduler-status))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error removing schedule entry:" error))
(alert "Error removing schedule entry"))))))
;; Enable scheduler
(defun enable-scheduler ()
(ps:chain
@ -1253,6 +1307,8 @@
(setf (ps:@ window enable-scheduler) enable-scheduler)
(setf (ps:@ window disable-scheduler) disable-scheduler)
(setf (ps:@ window load-current-scheduled-playlist) load-current-scheduled-playlist)
(setf (ps:@ window add-schedule-entry) add-schedule-entry)
(setf (ps:@ window remove-schedule-entry) remove-schedule-entry)
))
"Compiled JavaScript for admin dashboard - generated at load time")

View File

@ -95,10 +95,53 @@
(stop-playlist-scheduler)
(start-playlist-scheduler))
;;; Schedule Management
;;; Schedule Management (Database-backed)
(defun load-schedule-from-db ()
"Load the playlist schedule from the database into *playlist-schedule*."
(handler-case
(with-db
(let ((rows (postmodern:query "SELECT hour, playlist FROM playlist_schedule ORDER BY hour")))
(when rows
(setf *playlist-schedule*
(mapcar (lambda (row)
(cons (first row) (second row)))
rows))
(format t "~&[SCHEDULER] Loaded ~a schedule entries from database~%" (length rows)))))
(error (e)
(format t "~&[SCHEDULER] Warning: Could not load schedule from DB: ~a~%" e)
(format t "~&[SCHEDULER] Using default schedule~%"))))
(defun save-schedule-entry-to-db (hour playlist-name)
"Save or update a schedule entry in the database."
(handler-case
(with-db
(postmodern:query
(:insert-into 'playlist_schedule
:set 'hour hour 'playlist playlist-name 'updated_at (:now))
:on-conflict-update 'hour
:update-set 'playlist playlist-name 'updated_at (:now)))
(error (e)
;; Try simpler upsert approach
(handler-case
(with-db
(postmodern:query
(format nil "INSERT INTO playlist_schedule (hour, playlist, updated_at) VALUES (~a, '~a', NOW()) ON CONFLICT (hour) DO UPDATE SET playlist = '~a', updated_at = NOW()"
hour playlist-name playlist-name)))
(error (e2)
(format t "~&[SCHEDULER] Warning: Could not save schedule entry: ~a~%" e2))))))
(defun delete-schedule-entry-from-db (hour)
"Delete a schedule entry from the database."
(handler-case
(with-db
(postmodern:query (:delete-from 'playlist_schedule :where (:= 'hour hour))))
(error (e)
(format t "~&[SCHEDULER] Warning: Could not delete schedule entry: ~a~%" e))))
(defun add-scheduled-playlist (hour playlist-name)
"Add or update a playlist in the schedule."
"Add or update a playlist in the schedule (persists to database)."
(save-schedule-entry-to-db hour playlist-name)
(setf *playlist-schedule*
(cons (cons hour playlist-name)
(remove hour *playlist-schedule* :key #'car)))
@ -107,7 +150,8 @@
*playlist-schedule*)
(defun remove-scheduled-playlist (hour)
"Remove a playlist from the schedule."
"Remove a playlist from the schedule (persists to database)."
(delete-schedule-entry-from-db hour)
(setf *playlist-schedule*
(remove hour *playlist-schedule* :key #'car))
(when *scheduler-running*
@ -118,6 +162,13 @@
"Get the current playlist schedule as a sorted list."
(sort (copy-list *playlist-schedule*) #'< :key #'car))
(defun get-available-playlists ()
"Get list of available playlist files from the playlists directory."
(let ((playlists-dir (get-playlists-directory)))
(when (probe-file playlists-dir)
(mapcar #'file-namestring
(directory (merge-pathnames "*.m3u" playlists-dir))))))
(defun get-server-time-info ()
"Get current server time information in both UTC and local timezone."
(let* ((now (local-time:now))
@ -147,7 +198,8 @@
(require-role :admin)
(with-error-handling
(let* ((status (get-scheduler-status))
(time-info (getf status :server-time)))
(time-info (getf status :server-time))
(available-playlists (get-available-playlists)))
(api-output `(("status" . "success")
("enabled" . ,(if (getf status :enabled) t :json-false))
("running" . ,(if (getf status :running) t :json-false))
@ -158,7 +210,8 @@
("schedule" . ,(mapcar (lambda (entry)
`(("hour" . ,(car entry))
("playlist" . ,(cdr entry))))
(getf status :schedule))))))))
(getf status :schedule)))
("available_playlists" . ,(coerce available-playlists 'vector)))))))
(define-api asteroid/scheduler/enable () ()
"Enable the playlist scheduler"
@ -246,12 +299,19 @@
;;; Auto-start scheduler when database is connected
;;; This ensures the scheduler starts after the server is fully initialized
(define-trigger db:connected :after ()
(define-trigger db:connected ()
"Start the playlist scheduler after database connection is established"
(format t "~&[SCHEDULER] Database connected, starting playlist scheduler...~%")
(handler-case
(progn
;; Load schedule from database first
(load-schedule-from-db)
(start-playlist-scheduler)
;; Load the current scheduled playlist on startup
(let ((current-playlist (get-current-scheduled-playlist)))
(when current-playlist
(format t "~&[SCHEDULER] Loading current scheduled playlist: ~a~%" current-playlist)
(load-scheduled-playlist current-playlist)))
(format t "~&[SCHEDULER] Scheduler auto-started successfully~%"))
(error (e)
(format t "~&[SCHEDULER] Warning: Could not auto-start scheduler: ~a~%" e))))

View File

@ -253,16 +253,61 @@
<th>Time (UTC)</th>
<th>Playlist</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="scheduler-table-body">
<tr><td colspan="3" style="color: #888;">Loading...</td></tr>
<tr><td colspan="4" style="color: #888;">Loading...</td></tr>
</tbody>
</table>
<!-- Add New Schedule Entry -->
<div style="margin-top: 15px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
<h4 style="margin-top: 0;"> Add/Update Schedule Entry</h4>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<label>
Hour (UTC):
<select id="schedule-hour" style="margin-left: 5px; padding: 5px;">
<option value="0">00:00</option>
<option value="1">01:00</option>
<option value="2">02:00</option>
<option value="3">03:00</option>
<option value="4">04:00</option>
<option value="5">05:00</option>
<option value="6">06:00</option>
<option value="7">07:00</option>
<option value="8">08:00</option>
<option value="9">09:00</option>
<option value="10">10:00</option>
<option value="11">11:00</option>
<option value="12">12:00</option>
<option value="13">13:00</option>
<option value="14">14:00</option>
<option value="15">15:00</option>
<option value="16">16:00</option>
<option value="17">17:00</option>
<option value="18">18:00</option>
<option value="19">19:00</option>
<option value="20">20:00</option>
<option value="21">21:00</option>
<option value="22">22:00</option>
<option value="23">23:00</option>
</select>
</label>
<label>
Playlist:
<select id="schedule-playlist" style="margin-left: 5px; padding: 5px;">
<option value="">-- Select Playlist --</option>
</select>
</label>
<button class="btn btn-success" onclick="addScheduleEntry()"> Add/Update</button>
</div>
</div>
<p style="margin-top: 15px; font-size: 0.9em; color: #888;">
The scheduler automatically loads the appropriate playlist at each scheduled time.
Use "Load Current" to manually sync to the correct playlist for the current time.
Changes are saved to the database and persist across restarts.
</p>
</div>