Compare commits
No commits in common. "b415ca9530e881efb1b0cf6e85d1b402f6ab6d0d" and "f1ba25c6bffa90919011bf9b00e1182d6a6887aa" have entirely different histories.
b415ca9530
...
f1ba25c6bf
|
|
@ -28,7 +28,6 @@
|
|||
:cl-fad
|
||||
:bordeaux-threads
|
||||
:drakma
|
||||
:cl-cron
|
||||
;; radiance interfaces
|
||||
:i-log4cl
|
||||
:i-postmodern
|
||||
|
|
@ -61,7 +60,6 @@
|
|||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
(:file "stream-control")
|
||||
(:file "playlist-scheduler")
|
||||
(:file "listener-stats")
|
||||
(:file "auth-routes")
|
||||
(:file "frontend-partials")
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
-- 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 $$;
|
||||
|
|
@ -28,11 +28,8 @@
|
|||
(load-current-queue)
|
||||
(refresh-liquidsoap-status)
|
||||
(setup-stats-refresh)
|
||||
(refresh-scheduler-status)
|
||||
;; Update Liquidsoap status every 10 seconds
|
||||
(set-interval refresh-liquidsoap-status 10000)
|
||||
;; Update scheduler status every 30 seconds
|
||||
(set-interval refresh-scheduler-status 30000))))
|
||||
(set-interval refresh-liquidsoap-status 10000))))
|
||||
|
||||
;; Setup all event listeners
|
||||
(defun setup-event-listeners ()
|
||||
|
|
@ -1126,166 +1123,6 @@
|
|||
(set-interval refresh-listener-stats 30000)
|
||||
(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
|
||||
(setf (ps:@ window go-to-page) go-to-page)
|
||||
(setf (ps:@ window previous-page) previous-page)
|
||||
|
|
@ -1303,12 +1140,6 @@
|
|||
(setf (ps:@ window refresh-listener-stats) refresh-listener-stats)
|
||||
(setf (ps:@ window refresh-geo-stats) refresh-geo-stats)
|
||||
(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")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,317 +0,0 @@
|
|||
;;;; 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))))
|
||||
|
|
@ -77,131 +77,3 @@
|
|||
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/10 Making Gardens Out of Silence.flac
|
||||
#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
|
||||
#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
|
||||
|
|
|
|||
|
|
@ -214,103 +214,6 @@
|
|||
</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 -->
|
||||
<div class="admin-section">
|
||||
<h2>📡 Stream Control (Liquidsoap)</h2>
|
||||
|
|
|
|||
Loading…
Reference in New Issue