-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathplaylist-scheduler.lisp
More file actions
347 lines (310 loc) · 14.7 KB
/
playlist-scheduler.lisp
File metadata and controls
347 lines (310 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
;;;; 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 liquidsoap-command-succeeded-p (result)
"Check if a liquidsoap-command result indicates success.
Returns NIL if the result is empty, an error string, or otherwise invalid."
(and result
(stringp result)
(> (length (string-trim '(#\Space #\Newline #\Return) result)) 0)
(not (search "Error:" result :test #'char-equal))))
(defun liquidsoap-reload-and-skip (&key (max-retries 3) (retry-delay 2))
"Reload the playlist and skip the current track in Liquidsoap with retries.
First reloads the playlist file, then skips to trigger crossfade.
Retries up to MAX-RETRIES times with RETRY-DELAY seconds between attempts."
(let ((reload-ok nil)
(skip-ok nil))
;; Step 1: Reload the playlist file in Liquidsoap
(dotimes (attempt max-retries)
(let ((result (liquidsoap-command "stream-queue_m3u.reload")))
(when (liquidsoap-command-succeeded-p result)
(setf reload-ok t)
(return)))
(when (< attempt (1- max-retries))
(sleep retry-delay)))
;; Step 2: Skip current track to trigger crossfade to new playlist
(when reload-ok
(sleep 1)) ; Brief pause after reload before skipping
(dotimes (attempt max-retries)
(let ((result (liquidsoap-command "stream-queue_m3u.skip")))
(when (liquidsoap-command-succeeded-p result)
(setf skip-ok t)
(return)))
(when (< attempt (1- max-retries))
(sleep retry-delay)))
(values skip-ok reload-ok)))
(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
(copy-playlist-to-stream-queue playlist-path)
(load-queue-from-m3u-file)
(multiple-value-bind (skip-ok reload-ok)
(liquidsoap-reload-and-skip)
(if (and reload-ok skip-ok)
(log:info "Scheduler loaded ~a" playlist-name)
(log:error "Scheduler failed to switch to ~a (reload:~a skip:~a)"
playlist-name reload-ok skip-ok)))
t)
(progn
(log:error "Scheduler 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*
(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*
(dolist (entry *playlist-schedule*)
(let ((hour (car entry))
(playlist (cdr entry)))
(cl-cron:make-cron-job
(scheduled-playlist-loader hour playlist)
:minute 0
:hour hour)))
(setf *scheduler-running* t)))
(defun start-playlist-scheduler ()
"Start the playlist scheduler. Sets up cron jobs and starts cl-cron."
(setup-playlist-cron-jobs)
(cl-cron:start-cron)
t)
(defun stop-playlist-scheduler ()
"Stop the playlist scheduler."
(cl-cron:stop-cron)
(setf *scheduler-running* nil)
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))
(log:info "Scheduler loaded ~a entries from database" (length rows)))))
(error (e)
(log:warn "Scheduler DB load failed, using defaults: ~a" e))))
(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)
(log:warn "Scheduler 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)
(log:warn "Scheduler 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 and user-submissions."
(let ((playlists-dir (get-playlists-directory))
(submissions-dir (merge-pathnames "user-submissions/" (get-playlists-directory))))
(append
;; Main playlists directory
(when (probe-file playlists-dir)
(mapcar #'file-namestring
(directory (merge-pathnames "*.m3u" playlists-dir))))
;; User submissions directory (prefixed with user-submissions/)
(when (probe-file submissions-dir)
(mapcar (lambda (path)
(format nil "user-submissions/~a" (file-namestring path)))
(directory (merge-pathnames "*.m3u" submissions-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"
(handler-case
(progn
(load-schedule-from-db)
(start-playlist-scheduler)
(let ((current-playlist (get-current-scheduled-playlist)))
(when current-playlist
(load-scheduled-playlist current-playlist)))
(log:info "Playlist scheduler started"))
(error (e)
(log:error "Scheduler failed to start: ~a" e))))