From 923b1dc4eac7e1153913b7273eb37846d9be404e Mon Sep 17 00:00:00 2001 From: glenneth Date: Wed, 17 Dec 2025 14:22:53 +0300 Subject: [PATCH] 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) --- asteroid.asd | 2 + playlist-scheduler.lisp | 238 ++++++++++++++++++++++++++++++++++++ playlists/morning-drift.m3u | 128 +++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 playlist-scheduler.lisp diff --git a/asteroid.asd b/asteroid.asd index 147bde0..30d0713 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -28,6 +28,7 @@ :cl-fad :bordeaux-threads :drakma + :cl-cron ;; radiance interfaces :i-log4cl :i-postmodern @@ -60,6 +61,7 @@ (:file "user-management") (:file "playlist-management") (:file "stream-control") + (:file "playlist-scheduler") (:file "listener-stats") (:file "auth-routes") (:file "frontend-partials") diff --git a/playlist-scheduler.lisp b/playlist-scheduler.lisp new file mode 100644 index 0000000..a47e257 --- /dev/null +++ b/playlist-scheduler.lisp @@ -0,0 +1,238 @@ +;;;; 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 + +(defun add-scheduled-playlist (hour playlist-name) + "Add or update a playlist in the schedule." + (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." + (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-scheduler-status () + "Get the current status of the scheduler." + (list :enabled *scheduler-enabled* + :running *scheduler-running* + :current-playlist (get-current-scheduled-playlist) + :schedule (get-schedule))) + +;;; 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))) + (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)) + ("schedule" . ,(mapcar (lambda (entry) + `(("hour" . ,(car entry)) + ("playlist" . ,(cdr entry)))) + (getf status :schedule)))))))) + +(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 :after () + "Start the playlist scheduler after database connection is established" + (format t "~&[SCHEDULER] Database connected, starting playlist scheduler...~%") + (handler-case + (progn + (start-playlist-scheduler) + (format t "~&[SCHEDULER] Scheduler auto-started successfully~%")) + (error (e) + (format t "~&[SCHEDULER] Warning: Could not auto-start scheduler: ~a~%" e)))) diff --git a/playlists/morning-drift.m3u b/playlists/morning-drift.m3u index f8874a4..0f84bed 100644 --- a/playlists/morning-drift.m3u +++ b/playlists/morning-drift.m3u @@ -77,3 +77,131 @@ /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