diff --git a/migrations/004-playlist-schedule.sql b/migrations/004-playlist-schedule.sql new file mode 100644 index 0000000..d506ad4 --- /dev/null +++ b/migrations/004-playlist-schedule.sql @@ -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 $$; diff --git a/parenscript/admin.lisp b/parenscript/admin.lisp index 87a62df..0c30f89 100644 --- a/parenscript/admin.lisp +++ b/parenscript/admin.lisp @@ -1158,7 +1158,17 @@ (setf (ps:@ status-el inner-h-t-m-l) "🟡 Disabled")))) - ;; 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 "")) + (ps:chain available + (for-each (lambda (p) + (setf html (+ html ""))))) + (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 - "" + "" "" (if (< hour 10) "0" "") hour ":00 UTC" "" playlist "" - "" (if (= playlist (ps:@ data current_playlist)) "▶️ Active" "") "" + "" (if is-active "▶️ Active" "") "" + "" "")))))) (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") diff --git a/playlist-scheduler.lisp b/playlist-scheduler.lisp index b5e8685..392d2cf 100644 --- a/playlist-scheduler.lisp +++ b/playlist-scheduler.lisp @@ -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" @@ -251,6 +304,8 @@ (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))) diff --git a/template/admin.ctml b/template/admin.ctml index cfbce07..54232a8 100644 --- a/template/admin.ctml +++ b/template/admin.ctml @@ -253,16 +253,61 @@ Time (UTC) Playlist Status + Actions - Loading... + Loading... + +
+

➕ Add/Update Schedule Entry

+
+ + + +
+
+

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.