Compare commits

...

4 Commits

Author SHA1 Message Date
glenneth b415ca9530 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 20:48:07 -05:00
glenneth e2e3dbfbe0 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 20:48:07 -05:00
glenneth 6a80072075 feat: Add admin UI for playlist scheduler with server time display
- Add server time info (UTC) to scheduler status API
- Add scheduler section to admin.ctml with:
  - Server time display (UTC)
  - Current playlist indicator
  - Enable/Disable/Load Current buttons
  - Schedule table showing all time slots
- Add ParenScript functions for scheduler controls
- Auto-refresh scheduler status every 30 seconds
- Highlight active playlist in schedule table
2025-12-17 20:48:07 -05:00
glenneth 923b1dc4ea feat: Add automatic playlist scheduler with cl-cron
- Add playlist-scheduler.lisp with cl-cron based scheduling
- Schedule playlists at 00:00, 06:00, 12:00, 18:00 UTC
- Auto-start scheduler on database connection
- Add API endpoints for admin control:
  - /api/asteroid/scheduler/status
  - /api/asteroid/scheduler/enable
  - /api/asteroid/scheduler/disable
  - /api/asteroid/scheduler/load-current
  - /api/asteroid/scheduler/schedule
  - /api/asteroid/scheduler/update
  - /api/asteroid/scheduler/remove
- Add cl-cron dependency to asteroid.asd
- Extend morning-drift.m3u to ~6 hours (101 tracks)
2025-12-17 20:48:07 -05:00
6 changed files with 741 additions and 1 deletions

View File

@ -28,6 +28,7 @@
:cl-fad :cl-fad
:bordeaux-threads :bordeaux-threads
:drakma :drakma
:cl-cron
;; radiance interfaces ;; radiance interfaces
:i-log4cl :i-log4cl
:i-postmodern :i-postmodern
@ -60,6 +61,7 @@
(:file "user-management") (:file "user-management")
(:file "playlist-management") (:file "playlist-management")
(:file "stream-control") (:file "stream-control")
(:file "playlist-scheduler")
(:file "listener-stats") (:file "listener-stats")
(:file "auth-routes") (:file "auth-routes")
(:file "frontend-partials") (:file "frontend-partials")

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

@ -28,8 +28,11 @@
(load-current-queue) (load-current-queue)
(refresh-liquidsoap-status) (refresh-liquidsoap-status)
(setup-stats-refresh) (setup-stats-refresh)
(refresh-scheduler-status)
;; Update Liquidsoap status every 10 seconds ;; Update Liquidsoap status every 10 seconds
(set-interval refresh-liquidsoap-status 10000)))) (set-interval refresh-liquidsoap-status 10000)
;; Update scheduler status every 30 seconds
(set-interval refresh-scheduler-status 30000))))
;; Setup all event listeners ;; Setup all event listeners
(defun setup-event-listeners () (defun setup-event-listeners ()
@ -1123,6 +1126,166 @@
(set-interval refresh-listener-stats 30000) (set-interval refresh-listener-stats 30000)
(set-interval refresh-geo-stats 60000)) (set-interval refresh-geo-stats 60000))
;; ========================================
;; Playlist Scheduler Controls
;; ========================================
;; Refresh scheduler status
(defun refresh-scheduler-status ()
(ps:chain
(fetch "/api/asteroid/scheduler/status")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when (= (ps:@ data status) "success")
;; Update server time
(let ((time-el (ps:chain document (get-element-by-id "server-utc-time")))
(server-time (ps:@ data server_time)))
(when (and time-el server-time)
(setf (ps:@ time-el text-content) (ps:@ server-time utc))))
;; Update current playlist
(let ((playlist-el (ps:chain document (get-element-by-id "scheduler-current-playlist"))))
(when playlist-el
(setf (ps:@ playlist-el text-content) (or (ps:@ data current_playlist) "--"))))
;; Update status indicator
(let ((status-el (ps:chain document (get-element-by-id "scheduler-status-indicator"))))
(when status-el
(if (ps:@ data enabled)
(setf (ps:@ status-el inner-h-t-m-l)
"<span style=\"color: #00ff00;\">🟢 Enabled</span>")
(setf (ps:@ status-el inner-h-t-m-l)
"<span style=\"color: #ffaa00;\">🟡 Disabled</span>"))))
;; 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))))
(when (and table-body schedule)
(let ((html ""))
(ps:chain schedule
(for-each (lambda (entry)
(let* ((hour (ps:@ entry hour))
(playlist (ps:@ entry playlist))
(is-active (= playlist (ps:@ data current_playlist))))
(setf html
(+ html
"<tr" (if is-active " style=\"background: #1a3a1a;\"" "") ">"
"<td>" (if (< hour 10) "0" "") hour ":00 UTC</td>"
"<td>" playlist "</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
(fetch "/api/asteroid/scheduler/enable" (ps:create :method "POST"))
(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 "✓ Scheduler enabled")
(refresh-scheduler-status))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error enabling scheduler:" error))
(alert "Error enabling scheduler")))))
;; Disable scheduler
(defun disable-scheduler ()
(ps:chain
(fetch "/api/asteroid/scheduler/disable" (ps:create :method "POST"))
(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 "⏸️ Scheduler disabled")
(refresh-scheduler-status))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error disabling scheduler:" error))
(alert "Error disabling scheduler")))))
;; Load current scheduled playlist
(defun load-current-scheduled-playlist ()
(ps:chain
(fetch "/api/asteroid/scheduler/load-current" (ps:create :method "POST"))
(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 (+ "✓ Loaded " (ps:@ data playlist)))
(refresh-scheduler-status)
(load-current-queue))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading scheduled playlist:" error))
(alert "Error loading scheduled playlist")))))
;; Make functions globally accessible for onclick handlers ;; Make functions globally accessible for onclick handlers
(setf (ps:@ window go-to-page) go-to-page) (setf (ps:@ window go-to-page) go-to-page)
(setf (ps:@ window previous-page) previous-page) (setf (ps:@ window previous-page) previous-page)
@ -1140,6 +1303,12 @@
(setf (ps:@ window refresh-listener-stats) refresh-listener-stats) (setf (ps:@ window refresh-listener-stats) refresh-listener-stats)
(setf (ps:@ window refresh-geo-stats) refresh-geo-stats) (setf (ps:@ window refresh-geo-stats) refresh-geo-stats)
(setf (ps:@ window setup-stats-refresh) setup-stats-refresh) (setf (ps:@ window setup-stats-refresh) setup-stats-refresh)
(setf (ps:@ window refresh-scheduler-status) refresh-scheduler-status)
(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") "Compiled JavaScript for admin dashboard - generated at load time")

317
playlist-scheduler.lisp Normal file
View File

@ -0,0 +1,317 @@
;;;; playlist-scheduler.lisp - Automatic Playlist Scheduling for Asteroid Radio
;;;; Uses cl-cron to load time-based playlists at scheduled times
(in-package :asteroid)
;;; Scheduler Configuration
(defvar *playlist-schedule*
'((0 . "midnight-ambient.m3u") ; 00:00 UTC
(6 . "morning-drift.m3u") ; 06:00 UTC
(12 . "afternoon-orbit.m3u") ; 12:00 UTC
(18 . "evening-descent.m3u")) ; 18:00 UTC
"Association list mapping hours (UTC) to playlist filenames.
Each entry is (hour . playlist-filename).")
(defvar *scheduler-enabled* t
"When true, the playlist scheduler is active.")
(defvar *scheduler-running* nil
"Internal flag tracking if scheduler cron jobs are registered.")
;;; Scheduler Functions
(defun get-scheduled-playlist-for-hour (hour)
"Get the playlist filename scheduled for a given hour.
Returns the playlist for the most recent scheduled time slot."
(let ((sorted-schedule (sort (copy-list *playlist-schedule*) #'> :key #'car)))
(or (cdr (find-if (lambda (entry) (<= (car entry) hour)) sorted-schedule))
(cdar (last sorted-schedule)))))
(defun get-current-scheduled-playlist ()
"Get the playlist that should be playing right now based on UTC time."
(let ((current-hour (local-time:timestamp-hour (local-time:now) :timezone local-time:+utc-zone+)))
(get-scheduled-playlist-for-hour current-hour)))
(defun load-scheduled-playlist (playlist-name)
"Load a playlist by name, copying it to stream-queue.m3u and triggering playback."
(let ((playlist-path (merge-pathnames playlist-name (get-playlists-directory))))
(if (probe-file playlist-path)
(progn
(format t "~&[SCHEDULER] Loading playlist: ~a~%" playlist-name)
(copy-playlist-to-stream-queue playlist-path)
(load-queue-from-m3u-file)
(handler-case
(progn
(liquidsoap-command "stream-queue_m3u.skip")
(format t "~&[SCHEDULER] Playlist ~a loaded and crossfade triggered~%" playlist-name))
(error (e)
(format t "~&[SCHEDULER] Warning: Could not skip track: ~a~%" e)))
t)
(progn
(format t "~&[SCHEDULER] Error: Playlist not found: ~a~%" playlist-name)
nil))))
(defun scheduled-playlist-loader (hour playlist-name)
"Create a function that loads a specific playlist. Used by cl-cron jobs."
(lambda ()
(when *scheduler-enabled*
(format t "~&[SCHEDULER] Triggered at hour ~a UTC - loading ~a~%" hour playlist-name)
(load-scheduled-playlist playlist-name))))
;;; Cron Job Management
(defun setup-playlist-cron-jobs ()
"Set up cl-cron jobs for all scheduled playlists."
(unless *scheduler-running*
(format t "~&[SCHEDULER] Setting up playlist schedule:~%")
(dolist (entry *playlist-schedule*)
(let ((hour (car entry))
(playlist (cdr entry)))
(format t "~&[SCHEDULER] ~2,'0d:00 UTC -> ~a~%" hour playlist)
(cl-cron:make-cron-job
(scheduled-playlist-loader hour playlist)
:minute 0
:hour hour)))
(setf *scheduler-running* t)
(format t "~&[SCHEDULER] Playlist schedule configured~%")))
(defun start-playlist-scheduler ()
"Start the playlist scheduler. Sets up cron jobs and starts cl-cron."
(setup-playlist-cron-jobs)
(cl-cron:start-cron)
(format t "~&[SCHEDULER] Playlist scheduler started~%")
t)
(defun stop-playlist-scheduler ()
"Stop the playlist scheduler."
(cl-cron:stop-cron)
(setf *scheduler-running* nil)
(format t "~&[SCHEDULER] Playlist scheduler stopped~%")
t)
(defun restart-playlist-scheduler ()
"Restart the playlist scheduler with current configuration."
(stop-playlist-scheduler)
(start-playlist-scheduler))
;;; 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 (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)))
(when *scheduler-running*
(restart-playlist-scheduler))
*playlist-schedule*)
(defun remove-scheduled-playlist (hour)
"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*
(restart-playlist-scheduler))
*playlist-schedule*)
(defun get-schedule ()
"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))
(utc-hour (local-time:timestamp-hour now :timezone local-time:+utc-zone+))
(utc-minute (local-time:timestamp-minute now :timezone local-time:+utc-zone+)))
(list :utc-time (local-time:format-timestring nil now
:format '(:year "-" (:month 2) "-" (:day 2) " " (:hour 2) ":" (:min 2) ":" (:sec 2) " UTC")
:timezone local-time:+utc-zone+)
:utc-hour utc-hour
:utc-minute utc-minute
:local-time (local-time:format-timestring nil now
:format '(:year "-" (:month 2) "-" (:day 2) " " (:hour 2) ":" (:min 2) ":" (:sec 2))))))
(defun get-scheduler-status ()
"Get the current status of the scheduler."
(let ((time-info (get-server-time-info)))
(list :enabled *scheduler-enabled*
:running *scheduler-running*
:current-playlist (get-current-scheduled-playlist)
:schedule (get-schedule)
:server-time time-info)))
;;; API Endpoints for Admin Interface
(define-api asteroid/scheduler/status () ()
"Get the current scheduler status"
(require-role :admin)
(with-error-handling
(let* ((status (get-scheduler-status))
(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))
("current_playlist" . ,(getf status :current-playlist))
("server_time" . (("utc" . ,(getf time-info :utc-time))
("utc_hour" . ,(getf time-info :utc-hour))
("local" . ,(getf time-info :local-time))))
("schedule" . ,(mapcar (lambda (entry)
`(("hour" . ,(car entry))
("playlist" . ,(cdr entry))))
(getf status :schedule)))
("available_playlists" . ,(coerce available-playlists 'vector)))))))
(define-api asteroid/scheduler/enable () ()
"Enable the playlist scheduler"
(require-role :admin)
(with-error-handling
(setf *scheduler-enabled* t)
(unless *scheduler-running*
(start-playlist-scheduler))
(api-output `(("status" . "success")
("message" . "Scheduler enabled")))))
(define-api asteroid/scheduler/disable () ()
"Disable the playlist scheduler (stops automatic playlist changes)"
(require-role :admin)
(with-error-handling
(setf *scheduler-enabled* nil)
(api-output `(("status" . "success")
("message" . "Scheduler disabled - playlists will not auto-change")))))
(define-api asteroid/scheduler/load-current () ()
"Manually load the playlist that should be playing now based on schedule"
(require-role :admin)
(with-error-handling
(let ((playlist (get-current-scheduled-playlist)))
(if (load-scheduled-playlist playlist)
(api-output `(("status" . "success")
("message" . ,(format nil "Loaded scheduled playlist: ~a" playlist))
("playlist" . ,playlist)))
(api-output `(("status" . "error")
("message" . ,(format nil "Failed to load playlist: ~a" playlist)))
:status 500)))))
(define-api asteroid/scheduler/schedule () ()
"Get the current playlist schedule"
(require-role :admin)
(with-error-handling
(api-output `(("status" . "success")
("schedule" . ,(mapcar (lambda (entry)
`(("hour" . ,(car entry))
("playlist" . ,(cdr entry))
("time_label" . ,(format nil "~2,'0d:00 UTC" (car entry)))))
(get-schedule)))))))
(define-api asteroid/scheduler/update (hour playlist) ()
"Add or update a scheduled playlist (hour is 0-23 UTC)"
(require-role :admin)
(with-error-handling
(let ((hour-int (parse-integer hour :junk-allowed t)))
(if (and hour-int (>= hour-int 0) (<= hour-int 23))
(let ((playlist-path (merge-pathnames playlist (get-playlists-directory))))
(if (probe-file playlist-path)
(progn
(add-scheduled-playlist hour-int playlist)
(api-output `(("status" . "success")
("message" . ,(format nil "Schedule updated: ~2,'0d:00 UTC -> ~a" hour-int playlist))
("schedule" . ,(mapcar (lambda (entry)
`(("hour" . ,(car entry))
("playlist" . ,(cdr entry))))
(get-schedule))))))
(api-output `(("status" . "error")
("message" . ,(format nil "Playlist not found: ~a" playlist)))
:status 404)))
(api-output `(("status" . "error")
("message" . "Invalid hour - must be 0-23"))
:status 400)))))
(define-api asteroid/scheduler/remove (hour) ()
"Remove a scheduled playlist"
(require-role :admin)
(with-error-handling
(let ((hour-int (parse-integer hour :junk-allowed t)))
(if (and hour-int (>= hour-int 0) (<= hour-int 23))
(progn
(remove-scheduled-playlist hour-int)
(api-output `(("status" . "success")
("message" . ,(format nil "Removed schedule for ~2,'0d:00 UTC" hour-int))
("schedule" . ,(mapcar (lambda (entry)
`(("hour" . ,(car entry))
("playlist" . ,(cdr entry))))
(get-schedule))))))
(api-output `(("status" . "error")
("message" . "Invalid hour - must be 0-23"))
:status 400)))))
;;; Auto-start scheduler when database is connected
;;; This ensures the scheduler starts after the server is fully initialized
(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

@ -77,3 +77,131 @@
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/10 Making Gardens Out of Silence.flac /app/music/Brian Eno/2022 - ForeverAndEverNoMore/10 Making Gardens Out of Silence.flac
#EXTINF:-1,Tangerine Dream - Virtue Is Its Own Reward #EXTINF:-1,Tangerine Dream - Virtue Is Its Own Reward
/app/music/Tangerine Dream - Ambient Monkeys (flac)/10 Tangerine Dream - Virtue Is Its Own Reward.flac /app/music/Tangerine Dream - Ambient Monkeys (flac)/10 Tangerine Dream - Virtue Is Its Own Reward.flac
#EXTINF:-1,Brian Eno - Small Craft On A Milk Sea
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A3 Small Craft On A Milk Sea.flac
#EXTINF:-1,Tycho - Horizon
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/02 - Horizon.flac
#EXTINF:-1,Four Tet - Two Thousand And Seventeen
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/02 Two Thousand And Seventeen.flac
#EXTINF:-1,Boards of Canada - Dayvan Cowboy
/app/music/Boards of Canada/Trans Canada Highway/01 - Dayvan Cowboy.mp3
#EXTINF:-1,Ulrich Schnauss - Melts into Air (2019 Version)
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/01. Melts into Air (2019 Version).flac
#EXTINF:-1,arovane - olopp_eleen
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/01. arovane - olopp_eleen.flac
#EXTINF:-1,Biosphere - Bergsbotn
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/03 - Bergsbotn.flac
#EXTINF:-1,F.S.Blumm & Nils Frahm - Perff
/app/music/F.S Blumm and Nils Frahm - Music For Wobbling Music Versus Gravity (2013 - WEB - FLAC)/02. F.S.Blumm & Nils Frahm - Perff.flac
#EXTINF:-1,Clark - Simple Homecoming Loop
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/02 - Simple Homecoming Loop.flac
#EXTINF:-1,Tycho - Slack
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/03 - Slack.flac
#EXTINF:-1,Johann Johannsson - A Game Of Croquet
/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/07 - A Game Of Croquet.flac
#EXTINF:-1,FSOL - Motioned
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/04 - Motioned.flac
#EXTINF:-1,Brian Eno - Flint March
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A4 Flint March.flac
#EXTINF:-1,Four Tet - Lush
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/05 Lush.flac
#EXTINF:-1,Boards of Canada - Turquoise Hexagon Sun
/app/music/Boards of Canada/Hi Scores/02 - Turquoise Hexagon Sun.mp3
#EXTINF:-1,Ulrich Schnauss - Love Grows Out of Thin Air (2019 Version)
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/02. Love Grows Out of Thin Air (2019 Version).flac
#EXTINF:-1,arovane - wirkung
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/02. arovane - wirkung.flac
#EXTINF:-1,Biosphere - Berg
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/05 - Berg.flac
#EXTINF:-1,God is an Astronaut - Komorebi
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/05. Komorebi.flac
#EXTINF:-1,Tycho - Weather
/app/music/Tycho - Simulcast (2020) [WEB FLAC]/01 - Weather.flac
#EXTINF:-1,Port Blue - Sunset Cruiser
/app/music/Port Blue - The Airship (2007)/04. Sunset Cruiser.flac
#EXTINF:-1,F.S.Blumm & Nils Frahm - Exercising Levitation
/app/music/F.S Blumm and Nils Frahm - Music For Wobbling Music Versus Gravity (2013 - WEB - FLAC)/07. F.S.Blumm & Nils Frahm - Exercising Levitation.flac
#EXTINF:-1,Four Tet - You Are Loved
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/08 You Are Loved.flac
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Asteroid 2467
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/01. Asteroid 2467.flac
#EXTINF:-1,Boards of Canada - Left Side Drive
/app/music/Boards of Canada/Trans Canada Highway/02 - Left Side Drive.mp3
#EXTINF:-1,Brian Eno - Who Gives a Thought
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/01 Who Gives a Thought.flac
#EXTINF:-1,arovane - find
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/03. arovane - find.flac
#EXTINF:-1,Tycho - Receiver
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/04 - Receiver.flac
#EXTINF:-1,Johann Johannsson - The Origins Of Time
/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/08 - The Origins Of Time.flac
#EXTINF:-1,FSOL - Lichaen
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/05 - Lichaen.flac
#EXTINF:-1,Biosphere - Kyle
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/06 - Kyle.flac
#EXTINF:-1,Port Blue - The Grand Staircase
/app/music/Port Blue - The Airship (2007)/03. The Grand Staircase.flac
#EXTINF:-1,Vector Lovers - City Lights From A Train
/app/music/Vector Lovers/2005 - Capsule For One/01 - City Lights From A Train.mp3
#EXTINF:-1,Four Tet - Teenage Birdsong
/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/04 - Four Tet - Teenage Birdsong.flac
#EXTINF:-1,Ulrich Schnauss - The Magic in You (2019 Version)
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/03. The Magic in You (2019 Version).flac
#EXTINF:-1,Boards of Canada - Oirectine
/app/music/Boards of Canada/Twoism/02 - Oirectine.mp3
#EXTINF:-1,Clark - Bench
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/03 - Bench.flac
#EXTINF:-1,Tycho - Alright
/app/music/Tycho - Simulcast (2020) [WEB FLAC]/02 - Alright.flac
#EXTINF:-1,Brian Eno - Lesser Heaven
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/C3 Lesser Heaven.flac
#EXTINF:-1,arovane - sloon
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/05. arovane - sloon.flac
#EXTINF:-1,God is an Astronaut - Epitaph
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/01. Epitaph.flac
#EXTINF:-1,F.S.Blumm & Nils Frahm - Silently Sharing
/app/music/F.S Blumm and Nils Frahm - Music For Wobbling Music Versus Gravity (2013 - WEB - FLAC)/10. F.S.Blumm & Nils Frahm - Silently Sharing.flac
#EXTINF:-1,Biosphere - Fjølhøgget
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/07 - Fjølhøgget.flac
#EXTINF:-1,Port Blue - Over Atlantic City
/app/music/Port Blue - The Airship (2007)/02. Over Atlantic City.flac
#EXTINF:-1,Johann Johannsson - Viva Voce
/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/09 - Viva Voce.flac
#EXTINF:-1,Tangerine Dream - Symphony in A-minor
/app/music/Tangerine Dream - Ambient Monkeys (flac)/02 Tangerine Dream - Symphony in A-minor.flac
#EXTINF:-1,Tycho - Epoch
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/05 - Epoch.flac
#EXTINF:-1,Four Tet - Memories
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/11 Memories.flac
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Return To Burlington
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/02. Return To Burlington.flac
#EXTINF:-1,Boards of Canada - Skyliner
/app/music/Boards of Canada/Trans Canada Highway/04 - Skyliner.mp3
#EXTINF:-1,Vector Lovers - Melodies And Memory
/app/music/Vector Lovers/2005 - Capsule For One/07 - Melodies And Memory.mp3
#EXTINF:-1,Brian Eno - Icarus or Blériot
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/03 Icarus or Blériot.flac
#EXTINF:-1,arovane - noondt
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/07. arovane - noondt.flac
#EXTINF:-1,FSOL - Symphony for Halia
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/14 - Symphony for Halia.flac
#EXTINF:-1,Biosphere - Straumen
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/12 - Straumen.flac
#EXTINF:-1,Port Blue - The Gentle Descent
/app/music/Port Blue - The Airship (2007)/12. The Gentle Descent.flac
#EXTINF:-1,Proem - As They Go
/app/music/Proem/2019 - As They Go/Proem - As They Go - 01 As They Go.flac
#EXTINF:-1,Tycho - Outer Sunset
/app/music/Tycho - Simulcast (2020) [WEB FLAC]/03 - Outer Sunset.flac
#EXTINF:-1,Four Tet - Planet
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/14 Planet.flac
#EXTINF:-1,Ulrich Schnauss - New Day Starts at Dawn (2019 Version)
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/07. New Day Starts at Dawn (2019 Version).flac
#EXTINF:-1,Clark - Goodnight Kiri
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/14 - Goodnight Kiri.flac
#EXTINF:-1,Biosphere - Steinfjord
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/13 - Steinfjord.flac
#EXTINF:-1,Tangerine Dream - Calyx Calamander
/app/music/Tangerine Dream - Ambient Monkeys (flac)/04 Tangerine Dream - Calyx Calamander.flac
#EXTINF:-1,Vector Lovers - To The Stars
/app/music/Vector Lovers/2005 - Capsule For One/12 - To The Stars.mp3

View File

@ -214,6 +214,103 @@
</div> </div>
</div> </div>
<!-- Playlist Scheduler -->
<div class="admin-section">
<h2>⏰ Playlist Scheduler</h2>
<p>Automatically load playlists at scheduled times (UTC).</p>
<!-- Server Time Display -->
<div id="scheduler-time" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px;">
<div>
<strong>🕐 Server Time (UTC):</strong><br>
<span id="server-utc-time" style="font-size: 1.2em; font-family: monospace;">--:--:--</span>
</div>
<div>
<strong>📋 Current Playlist:</strong><br>
<span id="scheduler-current-playlist" style="font-size: 1.1em;">--</span>
</div>
<div>
<strong>⚡ Scheduler Status:</strong><br>
<span id="scheduler-status-indicator">--</span>
</div>
</div>
</div>
<!-- Scheduler Controls -->
<div class="queue-controls" style="margin-bottom: 15px;">
<button id="scheduler-refresh" class="btn btn-secondary" onclick="refreshSchedulerStatus()">🔄 Refresh</button>
<button id="scheduler-enable" class="btn btn-success" onclick="enableScheduler()">▶️ Enable</button>
<button id="scheduler-disable" class="btn btn-warning" onclick="disableScheduler()">⏸️ Disable</button>
<button id="scheduler-load-current" class="btn btn-info" onclick="loadCurrentScheduledPlaylist()">📂 Load Current</button>
</div>
<!-- Schedule Table -->
<h3>📅 Schedule</h3>
<table class="listener-stats-table" style="margin-top: 10px;">
<thead>
<tr>
<th>Time (UTC)</th>
<th>Playlist</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="scheduler-table-body">
<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>
<!-- Liquidsoap Stream Control --> <!-- Liquidsoap Stream Control -->
<div class="admin-section"> <div class="admin-section">
<h2>📡 Stream Control (Liquidsoap)</h2> <h2>📡 Stream Control (Liquidsoap)</h2>