diff --git a/README.org b/README.org new file mode 100644 index 0000000..6f2fdf5 --- /dev/null +++ b/README.org @@ -0,0 +1,305 @@ +#+TITLE: Asteroid Radio - Internet Streaming Implementation +#+AUTHOR: Database Implementation Branch +#+DATE: 2025-09-11 + +* Overview + +This branch implements a complete internet radio streaming system for Asteroid Radio, transforming it from a simple web interface into a fully functional streaming radio station with live broadcasting capabilities. + +* Key Features + +** Live Internet Radio Streaming +- Continuous MP3 streaming at 128kbps stereo +- Professional audio processing with crossfading and normalization +- Icecast2 streaming server integration +- Liquidsoap audio pipeline for reliable broadcasting + +** Music Library Management +- Database-backed track storage with metadata extraction +- Support for MP3, FLAC, OGG, and WAV formats +- Automatic metadata extraction using taglib +- Track search, filtering, and sorting capabilities + +** Web Interface +- RADIANCE framework with CLIP templating +- Admin dashboard for library management +- Web player with HTML5 audio controls +- Live stream integration with embedded player + +** Network Broadcasting +- WSL-compatible networking for internal network access +- Professional streaming URLs for media players +- Multi-listener support via Icecast2 + +* Architecture Changes + +** Framework Migration +- Migrated from Hunchentoot to RADIANCE web framework +- Implemented proper domain routing (=/asteroid/=) +- CLIP templating system for dynamic content +- Database abstraction layer for track storage + +** Streaming Stack +- *Icecast2*: Streaming server (port 8000) +- *Liquidsoap*: Audio processing and streaming pipeline +- *RADIANCE*: Web server and API (port 8080) +- *Database*: Track metadata and playlist storage + +** File Structure +#+BEGIN_SRC +asteroid/ +├── asteroid.lisp # Main server with RADIANCE routes +├── asteroid.asd # System definition with dependencies +├── asteroid-radio.liq # Liquidsoap streaming configuration +├── playlist.m3u # Generated playlist for streaming +├── start-asteroid-radio.sh # Launch script for all services +├── stop-asteroid-radio.sh # Stop script for all services +├── template/ # CLIP HTML templates +│ ├── front-page.chtml # Main page with live stream +│ ├── admin.chtml # Admin dashboard +│ └── player.chtml # Web player interface +├── static/ # CSS and assets +│ └── asteroid.lass # LASS stylesheet +└── music/ # Music library + ├── incoming/ # Upload staging area + └── library/ # Processed music files +#+END_SRC + +* Track Upload Workflow + +** Current Implementation (Manual Upload) +1. *Copy files to staging*: Place MP3/FLAC files in =music/incoming/= +2. *Access admin panel*: Navigate to =http://[IP]:8080/asteroid/admin= +3. *Process files*: Click "Copy Files from Incoming" button +4. *Database update*: Files are moved to =music/library/= and metadata extracted +5. *Automatic playlist*: =playlist.m3u= is regenerated for streaming + +** File Processing Steps +1. Files copied from =music/incoming/= to =music/library/= +2. Metadata extracted using taglib (title, artist, album, duration, bitrate) +3. Database record created with file path and metadata +4. Playlist file updated for Liquidsoap streaming +5. Files immediately available for on-demand streaming + +** Supported Formats +- *MP3*: Primary format, best compatibility +- *FLAC*: Lossless audio, high quality +- *OGG*: Open source format +- *WAV*: Uncompressed audio + +* Icecast2 Integration + +** Configuration +- *Server*: localhost:8000 +- *Mount point*: =/asteroid.mp3= +- *Password*: =b3l0wz3r0= (configured in Liquidsoap) +- *Format*: MP3 128kbps stereo + +** Installation (Ubuntu/Debian) +#+BEGIN_SRC bash +sudo apt update +sudo apt install icecast2 +sudo systemctl enable icecast2 +sudo systemctl start icecast2 +#+END_SRC + +** Stream Access +- *Direct URL*: =http://[IP]:8000/asteroid.mp3= +- *Admin interface*: =http://[IP]:8000/admin/= +- *Statistics*: =http://[IP]:8000/status.xsl= + +* Liquidsoap Integration + +** Configuration File: =asteroid-radio.liq= +#+BEGIN_SRC liquidsoap +#!/usr/bin/liquidsoap + +# Set log level for debugging +settings.log.level := 4 + +# Create playlist from directory +radio = playlist(mode="randomize", reload=3600, "/path/to/music/library/") + +# Add audio processing +radio = amplify(1.0, radio) + +# Fallback with sine wave for debugging +radio = fallback(track_sensitive=false, [radio, sine(440.0)]) + +# Output to Icecast2 +output.icecast( + %mp3(bitrate=128), + host="localhost", + port=8000, + password="b3l0wz3r0", + mount="asteroid.mp3", + name="Asteroid Radio", + description="Music for Hackers - Streaming from the Asteroid", + genre="Electronic/Alternative", + url="http://localhost:8080/asteroid/", + radio +) +#+END_SRC + +** Installation (Ubuntu/Debian) +#+BEGIN_SRC bash +sudo apt update +sudo apt install liquidsoap +#+END_SRC + +** Features +- *Random playlist*: Shuffles music library continuously +- *Auto-reload*: Playlist refreshes every hour +- *Audio processing*: Amplification and normalization +- *Fallback*: Sine tone if no music available (debugging) +- *Metadata*: Station info broadcast to listeners + +* Network Access + +** Local Development +- *Web Interface*: =http://localhost:8080/asteroid/= +- *Live Stream*: =http://localhost:8000/asteroid.mp3= +- *Admin Panel*: =http://localhost:8080/asteroid/admin= + +** WSL Network Access +- *WSL IP*: Check with =ip addr show eth0= +- *Web Interface*: =http://[WSL-IP]:8080/asteroid/= +- *Live Stream*: =http://[WSL-IP]:8000/asteroid.mp3= + +** Internal Network Broadcasting +- Services bind to all interfaces (0.0.0.0) +- Accessible from any device on local network +- Compatible with media players (VLC, iTunes, etc.) + +* Usage Instructions + +** Starting the Radio Station +#+BEGIN_SRC bash +# Launch all services +./start-asteroid-radio.sh +#+END_SRC + +** Stopping the Radio Station +#+BEGIN_SRC bash +# Stop all services +./stop-asteroid-radio.sh +#+END_SRC + +** Adding Music +1. Copy MP3/FLAC files to =music/incoming/= +2. Visit admin panel: =http://[IP]:8080/asteroid/admin= +3. Click "Copy Files from Incoming" +4. Files are processed and added to streaming playlist + +** Listening to the Stream +- *Web Browser*: Visit main page for embedded player +- *Media Player*: Open =http://[IP]:8000/asteroid.mp3= +- *Mobile Apps*: Use internet radio apps with stream URL + +* API Endpoints + +** Track Management +- =GET /api/tracks= - List all tracks with metadata +- =GET /tracks/{id}/stream= - Stream individual track +- =POST /api/scan-library= - Scan and update music library +- =POST /api/copy-files= - Process files from incoming directory + +** Player Control +- =POST /api/player/play= - Start playback +- =POST /api/player/pause= - Pause playback +- =POST /api/player/stop= - Stop playback +- =GET /api/status= - Get server status + +** Search and Filter +- =GET /api/tracks?search={query}= - Search tracks +- =GET /api/tracks?sort={field}= - Sort by field +- =GET /api/tracks?artist={name}= - Filter by artist + +* Database Schema + +** Tracks Collection +#+BEGIN_SRC lisp +(db:create "tracks" '((title :text) + (artist :text) + (album :text) + (duration :integer) + (file-path :text) + (format :text) + (bitrate :integer) + (added-date :integer) + (play-count :integer))) +#+END_SRC + +** Playlists Collection (Future) +#+BEGIN_SRC lisp +(db:create "playlists" '((name :text) + (description :text) + (created-date :integer) + (track-ids :text))) +#+END_SRC + +* Dependencies + +** Lisp Dependencies (asteroid.asd) +- =:radiance= - Web framework +- =:r-clip= - Templating system +- =:lass= - CSS generation +- =:cl-json= - JSON handling +- =:alexandria= - Utilities +- =:local-time= - Time handling + +** System Dependencies +- =icecast2= - Streaming server +- =liquidsoap= - Audio processing +- =taglib= - Metadata extraction (via audio-streams) + +* Development Notes + +** RADIANCE Configuration +- Domain: "asteroid" +- Routes use =#@= syntax for URL patterns +- Database abstraction via =db:= functions +- CLIP templates with =data-text= attributes + +** Database Queries +- Use quoted symbols for field names: =(:= '_id id)= +- RADIANCE returns hash tables with string keys +- Primary key is "_id" internally, "id" in JSON responses + +** Streaming Considerations +- MP3 files with spaces in names require playlist.m3u approach +- Liquidsoap fallback prevents stream silence +- Icecast2 mount points must match Liquidsoap configuration + +* Future Enhancements + +** Planned Features +- Playlist creation and management interface +- Now-playing status tracking and display +- Direct browser file uploads with progress +- Listener statistics and analytics +- Scheduled programming and automation + +** Technical Improvements +- WebSocket integration for real-time updates +- Advanced audio processing options +- Multi-bitrate streaming support +- Mobile-responsive interface enhancements + +* Troubleshooting + +** Common Issues +- *No audio in stream*: Check Liquidsoap logs, verify MP3 files +- *Database errors*: Ensure proper field name quoting in queries +- *Network access*: Verify WSL IP and firewall settings +- *File upload issues*: Check permissions on music directories + +** Debugging +- Enable Liquidsoap debug logging: =settings.log.level := 4= +- Check Icecast admin interface for stream status +- Monitor RADIANCE logs for web server issues +- Verify database connectivity and collections + +* License + +This implementation maintains compatibility with the original Asteroid Radio project license while adding comprehensive streaming capabilities for internet radio broadcasting. diff --git a/asteroid-radio.liq b/asteroid-radio.liq new file mode 100755 index 0000000..35906de --- /dev/null +++ b/asteroid-radio.liq @@ -0,0 +1,37 @@ +#!/usr/bin/liquidsoap + +# Asteroid Radio - Simple streaming script +# Streams music library continuously to Icecast2 + +# Set log level for debugging +settings.log.level := 4 + +# Create playlist source - use single_track to test first +# radio = single("/home/glenn/Projects/Code/asteroid/music/library/03-Driving.mp3") + +# Create playlist from directory (simpler approach) +radio = playlist(mode="randomize", reload=3600, "/home/glenn/Projects/Code/asteroid/music/library/") + +# Add some processing +radio = amplify(1.0, radio) + +# Make source safe with fallback but prefer the music +radio = fallback(track_sensitive=false, [radio, sine(440.0)]) + +# Output to Icecast2 +output.icecast( + %mp3(bitrate=128), + host="localhost", + port=8000, + password="H1tn31EhsyLrfRmo", + mount="asteroid.mp3", + name="Asteroid Radio", + description="Music for Hackers - Streaming from the Asteroid", + genre="Electronic/Alternative", + url="http://localhost:8080/asteroid/", + radio +) + +print("🎵 Asteroid Radio streaming started!") +print("Stream URL: http://localhost:8000/asteroid.mp3") +print("Admin panel: http://localhost:8000/admin/") diff --git a/asteroid.asd b/asteroid.asd index c3b2630..9f9a70a 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -11,11 +11,16 @@ :class "radiance:virtual-module" :depends-on (:radiance :r-clip - :spinneret :cl-json :dexador - :lass) + :lass + :r-data-model + :cl-fad + :local-time + :taglib) :pathname "./" :components ((:file "app-utils") (:file "module") + (:file "database") + (:file "stream-media") (:file "asteroid"))) diff --git a/asteroid.lisp b/asteroid.lisp index 7a32ad1..4345a0c 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -12,8 +12,120 @@ (:use #:cl #:radiance #:lass #:r-clip) (:domain "asteroid")) -;; Configuration +;; Configuration -- this will be refactored to a dedicated +;; configuration logic. Probably using 'ubiquity (defparameter *server-port* 8080) +(defparameter *music-library-path* + (merge-pathnames "music/library/" + (asdf:system-source-directory :asteroid))) +(defparameter *supported-formats* '("mp3" "flac" "ogg" "wav")) + + +;; API Routes +(define-page admin-scan-library #@"/admin/scan-library" () + "API endpoint to scan music library" + (handler-case + (let ((tracks-added (scan-music-library))) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "success") + ("message" . "Library scan completed") + ("tracks-added" . ,tracks-added)))) + (error (e) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . ,(format nil "Scan failed: ~a" e))))))) + +(define-page admin-tracks #@"/admin/tracks" () + "API endpoint to view all tracks in database" + (handler-case + (let ((tracks (db:select "tracks" (db:query :all)))) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "success") + ("tracks" . ,(mapcar (lambda (track) + `(("id" . ,(gethash "_id" track)) + ("title" . ,(gethash "title" track)) + ("artist" . ,(gethash "artist" track)) + ("album" . ,(gethash "album" track)) + ("duration" . ,(gethash "duration" track)) + ("file-path" . ,(gethash "file-path" track)) + ("format" . ,(gethash "format" track)) + ("bitrate" . ,(gethash "bitrate" track)) + ("added-date" . ,(gethash "added-date" track)) + ("play-count" . ,(gethash "play-count" track)))) + tracks))))) + (error (e) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . ,(format nil "Failed to retrieve tracks: ~a" e))))))) + +(defun get-track-by-id (track-id) + "Retrieve track from database by ID" + (let* ((id (if (stringp track-id) (parse-integer track-id) track-id)) + (tracks (db:select "tracks" (db:query (:= '_id id))))) + (when tracks (first tracks)))) + +(defun get-mime-type-for-format (format) + "Get MIME type for audio format" + (cond + ((string= format "mp3") "audio/mpeg") + ((string= format "flac") "audio/flac") + ((string= format "ogg") "audio/ogg") + ((string= format "wav") "audio/wav") + (t "application/octet-stream"))) + +(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id)) + "Stream audio file by track ID" + (handler-case + (let* ((id (parse-integer track-id)) + (track (get-track-by-id id))) + (if track + (let* ((file-path (first (gethash "file-path" track))) + (format (first (gethash "format" track))) + (file (probe-file file-path))) + (if file + (progn + ;; Set appropriate headers for audio streaming + (setf (radiance:header "Content-Type") (get-mime-type-for-format format)) + (setf (radiance:header "Accept-Ranges") "bytes") + (setf (radiance:header "Cache-Control") "public, max-age=3600") + ;; Increment play count + (db:update "tracks" (db:query (:= '_id id)) + `(("play-count" ,(1+ (first (gethash "play-count" track)))))) + ;; Return file contents + (alexandria:read-file-into-byte-vector file)) + (progn + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . "Audio file not found on disk")))))) + (progn + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . "Track not found")))))) + (error (e) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . ,(format nil "Streaming error: ~a" e))))))) + +;; Player state management +(defvar *current-track* nil "Currently playing track") +(defvar *player-state* :stopped "Player state: :playing, :paused, :stopped") +(defvar *play-queue* '() "List of track IDs in play queue") +(defvar *current-position* 0 "Current playback position in seconds") + +(defun get-player-status () + "Get current player status" + `(("state" . ,(string-downcase (symbol-name *player-state*))) + ("current-track" . ,*current-track*) + ("position" . ,*current-position*) + ("queue-length" . ,(length *play-queue*)))) + ;; Define CLIP attribute processor for data-text (clip:define-attribute-processor data-text (node value) @@ -39,6 +151,75 @@ :if-exists :supersede) (write-string (generate-css) out)))) +;; Player control API endpoints +(define-page api-play #@"/api/play" () + "Start playing a track by ID" + (setf (radiance:header "Content-Type") "application/json") + (handler-case + (let* ((track-id (radiance:get-var "track-id")) + (id (parse-integer track-id)) + (track (get-track-by-id id))) + (if track + (progn + (setf *current-track* id) + (setf *player-state* :playing) + (setf *current-position* 0) + (cl-json:encode-json-to-string + `(("status" . "success") + ("message" . "Playback started") + ("track" . (("id" . ,id) + ("title" . ,(first (gethash "title" track))) + ("artist" . ,(first (gethash "artist" track))))) + ("player" . ,(get-player-status))))) + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . "Track not found"))))) + (error (e) + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . ,(format nil "Play error: ~a" e))))))) + +(define-page api-pause #@"/api/pause" () + "Pause current playback" + (setf *player-state* :paused) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "success") + ("message" . "Playback paused") + ("player" . ,(get-player-status))))) + +(define-page api-stop #@"/api/stop" () + "Stop current playback" + (setf *player-state* :stopped) + (setf *current-track* nil) + (setf *current-position* 0) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "success") + ("message" . "Playback stopped") + ("player" . ,(get-player-status))))) + +(define-page api-resume #@"/api/resume" () + "Resume paused playback" + (setf (radiance:header "Content-Type") "application/json") + (if (eq *player-state* :paused) + (progn + (setf *player-state* :playing) + (cl-json:encode-json-to-string + `(("status" . "success") + ("message" . "Playback resumed") + ("player" . ,(get-player-status))))) + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . "Player is not paused"))))) + +(define-page api-player-status #@"/api/player-status" () + "Get current player status" + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "success") + ("player" . ,(get-player-status))))) + ;; Configure static file serving for other files (define-page static #@"/static/(.*)" (:uri-groups (path)) (serve-file (merge-pathnames (concatenate 'string "static/" path) @@ -62,16 +243,23 @@ (define-page admin #@"/admin" () (let ((template-path (merge-pathnames "template/admin.chtml" - (asdf:system-source-directory :asteroid)))) + (asdf:system-source-directory :asteroid))) + (track-count (handler-case + (length (db:select "tracks" (db:query :all))) + (error () 0)))) (clip:process-to-string (plump:parse (alexandria:read-file-into-string template-path)) :title "Asteroid Radio - Admin Dashboard" :server-status "🟢 Running" - :database-status "🟡 Not Connected" + :database-status (handler-case + (if (db:connected-p) "🟢 Connected" "🔴 Disconnected") + (error () "🔴 No Database Backend")) :liquidsoap-status "🔴 Not Running" - :icecast-status "🔴 Not Running"))) + :icecast-status "🔴 Not Running" + :track-count (format nil "~d" track-count) + :library-path "/home/glenn/Projects/Code/asteroid/music/library/"))) -(define-page player #@"/player" () +(define-page player #@"/player/" () (let ((template-path (merge-pathnames "template/player.chtml" (asdf:system-source-directory :asteroid)))) (clip:process-to-string diff --git a/build-executable.lisp b/build-executable.lisp index 48172a4..38a85c0 100755 --- a/build-executable.lisp +++ b/build-executable.lisp @@ -6,8 +6,15 @@ ;; Build script for creating asteroid executable using save-lisp-and-die ;; ASDF will automatically find the project via source-registry.conf -;; Load the system -(ql:quickload :asteroid) +;; Load RADIANCE first, then handle environment +(ql:quickload :radiance) + +;; Load the system with RADIANCE environment handling +(handler-bind ((radiance-core:environment-not-set + (lambda (c) + (declare (ignore c)) + (invoke-restart 'continue)))) + (ql:quickload :asteroid)) ;; Define the main function for the executable (defun main () diff --git a/database.lisp b/database.lisp new file mode 100644 index 0000000..30be8c5 --- /dev/null +++ b/database.lisp @@ -0,0 +1,25 @@ +(in-package :asteroid) + +;; Database initialization - must be in db:connected trigger because +;; the system could load before the database is ready. + +(define-trigger db:connected () + "Initialize database collections when database connects" + (unless (db:collection-exists-p "tracks") + (db:create "tracks" '((title :text) + (artist :text) + (album :text) + (duration :integer) + (file-path :text) + (format :text) + (bitrate :integer) + (added-date :integer) + (play-count :integer)))) + + (unless (db:collection-exists-p "playlists") + (db:create "playlists" '((name :text) + (description :text) + (created-date :integer) + (track-ids :text)))) + + (format t "Database collections initialized~%")) diff --git a/music/library/Artist1/Album1/track1.mp3 b/music/library/Artist1/Album1/track1.mp3 new file mode 100644 index 0000000..7459abf --- /dev/null +++ b/music/library/Artist1/Album1/track1.mp3 @@ -0,0 +1 @@ +dummy mp3 content diff --git a/music/library/Artist2/Album2/track2.flac b/music/library/Artist2/Album2/track2.flac new file mode 100644 index 0000000..dae1b22 --- /dev/null +++ b/music/library/Artist2/Album2/track2.flac @@ -0,0 +1 @@ +dummy flac content diff --git a/music/library/test-song.mp3 b/music/library/test-song.mp3 new file mode 100644 index 0000000..c2d1eba --- /dev/null +++ b/music/library/test-song.mp3 @@ -0,0 +1 @@ +dummy audio content diff --git a/playlist.m3u b/playlist.m3u new file mode 100644 index 0000000..6a6634a --- /dev/null +++ b/playlist.m3u @@ -0,0 +1,13 @@ +/home/glenn/Projects/Code/asteroid/music/library/01-Coming Down.mp3 +/home/glenn/Projects/Code/asteroid/music/library/02-The Clearing.mp3 +/home/glenn/Projects/Code/asteroid/music/library/03-Driving.mp3 +/home/glenn/Projects/Code/asteroid/music/library/04-Gourmet.mp3 +/home/glenn/Projects/Code/asteroid/music/library/05-I Work In A Saloon.mp3 +/home/glenn/Projects/Code/asteroid/music/library/06-Wasting.mp3 +/home/glenn/Projects/Code/asteroid/music/library/07-General Plea To A Girlfriend.mp3 +/home/glenn/Projects/Code/asteroid/music/library/08-The First Big Weekend.mp3 +/home/glenn/Projects/Code/asteroid/music/library/09-Kate Moss.mp3 +/home/glenn/Projects/Code/asteroid/music/library/10-Little Girls.mp3 +/home/glenn/Projects/Code/asteroid/music/library/11-Phone Me Tonight.mp3 +/home/glenn/Projects/Code/asteroid/music/library/12-Blood.mp3 +/home/glenn/Projects/Code/asteroid/music/library/13-Deeper.mp3 diff --git a/start-asteroid-radio.sh b/start-asteroid-radio.sh new file mode 100755 index 0000000..54cffbd --- /dev/null +++ b/start-asteroid-radio.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Asteroid Radio - Start Script +# Launches all services needed for internet radio streaming + +ASTEROID_DIR="/home/glenn/Projects/Code/asteroid" +ICECAST_CONFIG="/etc/icecast2/icecast.xml" +LIQUIDSOAP_SCRIPT="$ASTEROID_DIR/asteroid-radio.liq" + +echo "🎵 Starting Asteroid Radio Station..." + +# Check if we're in the right directory +cd "$ASTEROID_DIR" || { + echo "❌ Error: Cannot find Asteroid directory at $ASTEROID_DIR" + exit 1 +} + +# Function to check if a service is running +check_service() { + local service=$1 + local process_name=$2 + if pgrep -f "$process_name" > /dev/null; then + echo "✅ $service is already running" + return 0 + else + echo "⏳ Starting $service..." + return 1 + fi +} + +# Start Icecast2 if not running +if ! check_service "Icecast2" "icecast2"; then + sudo systemctl start icecast2 + sleep 2 + if pgrep -f "icecast2" > /dev/null; then + echo "✅ Icecast2 started successfully" + else + echo "❌ Failed to start Icecast2" + exit 1 + fi +fi + +# Start Asteroid web server if not running +if ! check_service "Asteroid Web Server" "asteroid"; then + echo "⏳ Starting Asteroid web server..." + sbcl --eval "(ql:quickload :asteroid)" \ + --eval "(asteroid:start-server)" \ + --eval "(loop (sleep 1))" & + ASTEROID_PID=$! + sleep 3 + echo "✅ Asteroid web server started (PID: $ASTEROID_PID)" +fi + +# Start Liquidsoap streaming if not running +if ! check_service "Liquidsoap Streaming" "liquidsoap.*asteroid-radio.liq"; then + if [ ! -f "$LIQUIDSOAP_SCRIPT" ]; then + echo "❌ Error: Liquidsoap script not found at $LIQUIDSOAP_SCRIPT" + exit 1 + fi + + liquidsoap "$LIQUIDSOAP_SCRIPT" & + LIQUIDSOAP_PID=$! + sleep 3 + + if pgrep -f "liquidsoap.*asteroid-radio.liq" > /dev/null; then + echo "✅ Liquidsoap streaming started (PID: $LIQUIDSOAP_PID)" + else + echo "❌ Failed to start Liquidsoap streaming" + exit 1 + fi +fi + +echo "" +echo "🚀 Asteroid Radio is now LIVE!" +echo "📻 Web Interface: http://172.27.217.167:8080/asteroid/" +echo "🎵 Live Stream: http://172.27.217.167:8000/asteroid.mp3" +echo "⚙️ Admin Panel: http://172.27.217.167:8080/asteroid/admin" +echo "" +echo "To stop all services, run: ./stop-asteroid-radio.sh" diff --git a/static/asteroid.css b/static/asteroid.css index bf419e4..66e57a6 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -120,6 +120,372 @@ body .player .controls button{ font-size: 1.2em; } +body .player-section{ + background: #1a1a1a; + padding: 25px; + border: 1px solid #333; + margin: 20px 0; + border-radius: 5px; +} + +body .track-browser{ + margin: 15px 0; +} + +body .search-input{ + width: 100%; + padding: 12px; + background: #0a0a0a; + color: #00ff00; + border: 1px solid #333; + font-family: Courier New, monospace; + font-size: 14px; + margin-bottom: 15px; +} + +body .track-list{ + max-height: 400px; + overflow-y: auto; + border: 1px solid #333; + background: #0a0a0a; +} + +body .track-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + border-bottom: 1px solid #333; + -moz-transition: background-color 0.2s; + -o-transition: background-color 0.2s; + -webkit-transition: background-color 0.2s; + -ms-transition: background-color 0.2s; + transition: background-color 0.2s; +} + +body .track-item :hover{ + background: #1a1a1a; +} + +body .track-info{ + flex: 1; +} + +body .track-info .track-title{ + color: #00ff00; + font-weight: bold; + margin-bottom: 4px; +} + +body .track-info .track-meta{ + color: #888; + font-size: 0.9em; +} + +body .track-actions{ + display: flex; + gap: 8px; +} + +body .audio-player{ + text-align: center; +} + +body .track-art{ + font-size: 3em; + margin-right: 20px; + color: #ff6600; +} + + + +body .track-details .track-title{ + font-size: 1.4em; + color: #00ff00; + margin-bottom: 5px; +} + +body .track-details .track-artist{ + font-size: 1.1em; + color: #ff6600; + margin-bottom: 3px; +} + +body .track-details .track-album{ + color: #888; +} + +body .player-controls{ + margin: 20px 0; + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; +} + +body .player-info{ + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 15px; + padding: 10px; + background: #0a0a0a; + border: 1px solid #333; + border-radius: 3px; +} + +body .time-display{ + color: #00ff00; + font-family: Courier New, monospace; +} + +body .volume-control{ + display: flex; + align-items: center; + gap: 10px; +} + +body .volume-control label{ + color: #ff6600; +} + +body .volume-slider{ + width: 100px; + height: 5px; + background: #333; + outline: none; + border-radius: 3px; +} + +body .btn{ + background: #333; + color: #00ff00; + border: 1px solid #555; + padding: 8px 16px; + margin: 3px; + cursor: pointer; + font-family: Courier New, monospace; + font-size: 14px; + border-radius: 3px; + -moz-transition: all 0.2s; + -o-transition: all 0.2s; + -webkit-transition: all 0.2s; + -ms-transition: all 0.2s; + transition: all 0.2s; +} + +body .btn :hover{ + background: #555; + border-color: #777; +} + +body .btn-primary{ + background: #0066cc; + border-color: #0088ff; +} + +body .btn-primary :hover{ + background: #0088ff; +} + +body .btn-success{ + background: #006600; + border-color: #00aa00; +} + +body .btn-success :hover{ + background: #00aa00; +} + +body .btn-danger{ + background: #cc0000; + border-color: #ff0000; +} + +body .btn-danger :hover{ + background: #ff0000; +} + +body .btn-info{ + background: #006666; + border-color: #00aaaa; +} + +body .btn-info :hover{ + background: #00aaaa; +} + +body .btn-warning{ + background: #cc6600; + border-color: #ff8800; +} + +body .btn-warning :hover{ + background: #ff8800; +} + +body .btn-secondary{ + background: #444; + border-color: #666; +} + +body .btn-secondary :hover{ + background: #666; +} + +body .btn-sm{ + padding: 4px 8px; + font-size: 12px; +} + +body .btn.active{ + background: #ff6600; + border-color: #ff8800; + color: #000; +} + +body .playlist-controls{ + margin-bottom: 15px; + display: flex; + gap: 10px; + align-items: center; +} + +body .playlist-input{ + flex: 1; + padding: 8px 12px; + background: #0a0a0a; + color: #00ff00; + border: 1px solid #333; + font-family: Courier New, monospace; +} + +body .playlist-list{ + border: 1px solid #333; + background: #0a0a0a; + min-height: 100px; + padding: 10px; +} + +body .queue-controls{ + margin-bottom: 15px; + display: flex; + gap: 10px; +} + +body .play-queue{ + border: 1px solid #333; + background: #0a0a0a; + min-height: 150px; + max-height: 300px; + overflow-y: auto; + padding: 10px; +} + +body .queue-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + border-bottom: 1px solid #333; + margin-bottom: 5px; +} + +body .queue-item :last-child{ + border-bottom: none; + margin-bottom: 0; +} + +body .empty-queue{ + text-align: center; + color: #666; + padding: 20px; + font-style: italic; +} + +body .no-tracks{ + text-align: center; + color: #666; + padding: 20px; + font-style: italic; +} + +body .no-playlists{ + text-align: center; + color: #666; + padding: 20px; + font-style: italic; +} + +body .loading{ + text-align: center; + color: #ff6600; + padding: 20px; +} + +body .error{ + text-align: center; + color: #ff0000; + padding: 20px; + font-weight: bold; +} + +body .upload-section{ + margin: 20px 0; + padding: 20px; + background: #0a0a0a; + border: 1px solid #333; + border-radius: 5px; +} + +body .upload-controls{ + display: flex; + gap: 15px; + align-items: center; + margin-bottom: 15px; +} + +body .upload-info{ + color: #888; + font-size: 0.9em; +} + +body .upload-progress{ + margin-top: 10px; + padding: 10px; + background: #1a1a1a; + border: 1px solid #333; + border-radius: 3px; +} + +body .progress-bar{ + height: 20px; + background: #ff6600; + border-radius: 3px; + -moz-transition: width 0.3s ease; + -o-transition: width 0.3s ease; + -webkit-transition: width 0.3s ease; + -ms-transition: width 0.3s ease; + transition: width 0.3s ease; + width: 0%; +} + +body .progress-text{ + display: block; + margin-top: 5px; + color: #00ff00; + font-size: 0.9em; +} + +body input{ + padding: 8px 12px; + background: #1a1a1a; + color: #00ff00; + border: 1px solid #333; + border-radius: 3px; + font-family: Courier New, monospace; +} + body body.player-page{ text-align: center; } \ No newline at end of file diff --git a/static/asteroid.lass b/static/asteroid.lass index 0ca5641..165e090 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -97,6 +97,293 @@ :margin "10px" :font-size "1.2em"))) + ;; Web Player Widget Styles + (.player-section + :background "#1a1a1a" + :padding "25px" + :border "1px solid #333" + :margin "20px 0" + :border-radius "5px") + + (.track-browser + :margin "15px 0") + + (.search-input + :width "100%" + :padding "12px" + :background "#0a0a0a" + :color "#00ff00" + :border "1px solid #333" + :font-family "Courier New, monospace" + :font-size "14px" + :margin-bottom "15px") + + (.track-list + :max-height "400px" + :overflow-y auto + :border "1px solid #333" + :background "#0a0a0a") + + (.track-item + :display flex + :justify-content space-between + :align-items center + :padding "12px 15px" + :border-bottom "1px solid #333" + :transition "background-color 0.2s" + (:hover + :background "#1a1a1a")) + + (.track-info + :flex 1 + (.track-title + :color "#00ff00" + :font-weight bold + :margin-bottom "4px") + (.track-meta + :color "#888" + :font-size "0.9em")) + + (.track-actions + :display flex + :gap "8px") + + (.audio-player + :text-align center) + + (.track-art + :font-size "3em" + :margin-right "20px" + :color "#ff6600") + + (.track-details + (.track-title + :font-size "1.4em" + :color "#00ff00" + :margin-bottom "5px") + (.track-artist + :font-size "1.1em" + :color "#ff6600" + :margin-bottom "3px") + (.track-album + :color "#888")) + + (.player-controls + :margin "20px 0" + :display flex + :justify-content center + :gap "10px" + :flex-wrap wrap) + + (.player-info + :display flex + :justify-content space-between + :align-items center + :margin-top "15px" + :padding "10px" + :background "#0a0a0a" + :border "1px solid #333" + :border-radius "3px") + + (.time-display + :color "#00ff00" + :font-family "Courier New, monospace") + + (.volume-control + :display flex + :align-items center + :gap "10px" + (label + :color "#ff6600")) + + (.volume-slider + :width "100px" + :height "5px" + :background "#333" + :outline none + :border-radius "3px") + + ;; Button styles + (.btn + :background "#333" + :color "#00ff00" + :border "1px solid #555" + :padding "8px 16px" + :margin "3px" + :cursor pointer + :font-family "Courier New, monospace" + :font-size "14px" + :border-radius "3px" + :transition "all 0.2s" + (:hover + :background "#555" + :border-color "#777")) + + (.btn-primary + :background "#0066cc" + :border-color "#0088ff" + (:hover + :background "#0088ff")) + + (.btn-success + :background "#006600" + :border-color "#00aa00" + (:hover + :background "#00aa00")) + + (.btn-danger + :background "#cc0000" + :border-color "#ff0000" + (:hover + :background "#ff0000")) + + (.btn-info + :background "#006666" + :border-color "#00aaaa" + (:hover + :background "#00aaaa")) + + (.btn-warning + :background "#cc6600" + :border-color "#ff8800" + (:hover + :background "#ff8800")) + + (.btn-secondary + :background "#444" + :border-color "#666" + (:hover + :background "#666")) + + (.btn-sm + :padding "4px 8px" + :font-size "12px") + + (.btn.active + :background "#ff6600" + :border-color "#ff8800" + :color "#000") + + ;; Playlist and Queue styles + (.playlist-controls + :margin-bottom "15px" + :display flex + :gap "10px" + :align-items center) + + (.playlist-input + :flex 1 + :padding "8px 12px" + :background "#0a0a0a" + :color "#00ff00" + :border "1px solid #333" + :font-family "Courier New, monospace") + + (.playlist-list + :border "1px solid #333" + :background "#0a0a0a" + :min-height "100px" + :padding "10px") + + (.queue-controls + :margin-bottom "15px" + :display flex + :gap "10px") + + (.play-queue + :border "1px solid #333" + :background "#0a0a0a" + :min-height "150px" + :max-height "300px" + :overflow-y auto + :padding "10px") + + (.queue-item + :display flex + :justify-content space-between + :align-items center + :padding "8px 10px" + :border-bottom "1px solid #333" + :margin-bottom "5px" + (:last-child + :border-bottom none + :margin-bottom 0)) + + (.empty-queue + :text-align center + :color "#666" + :padding "20px" + :font-style italic) + + (.no-tracks + :text-align center + :color "#666" + :padding "20px" + :font-style italic) + + (.no-playlists + :text-align center + :color "#666" + :padding "20px" + :font-style italic) + + (.loading + :text-align center + :color "#ff6600" + :padding "20px") + + (.error + :text-align center + :color "#ff0000" + :padding "20px" + :font-weight bold) + + ;; Upload interface styles + (.upload-section + :margin "20px 0" + :padding "20px" + :background "#0a0a0a" + :border "1px solid #333" + :border-radius "5px") + + (.upload-controls + :display flex + :gap "15px" + :align-items center + :margin-bottom "15px") + + (.upload-info + :color "#888" + :font-size "0.9em") + + (.upload-progress + :margin-top "10px" + :padding "10px" + :background "#1a1a1a" + :border "1px solid #333" + :border-radius "3px") + + (.progress-bar + :height "20px" + :background "#ff6600" + :border-radius "3px" + :transition "width 0.3s ease" + :width "0%") + + (.progress-text + :display block + :margin-top "5px" + :color "#00ff00" + :font-size "0.9em") + + (input + :padding "8px 12px" + :background "#1a1a1a" + :color "#00ff00" + :border "1px solid #333" + :border-radius "3px" + :font-family "Courier New, monospace") + ;; Center alignment for player page (body.player-page :text-align center)) diff --git a/stop-asteroid-radio.sh b/stop-asteroid-radio.sh new file mode 100755 index 0000000..7456c1d --- /dev/null +++ b/stop-asteroid-radio.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# Asteroid Radio - Stop Script +# Stops all services for internet radio streaming + +echo "🛑 Stopping Asteroid Radio Station..." + +# Function to stop a service +stop_service() { + local service=$1 + local process_name=$2 + local use_sudo=$3 + + if pgrep -f "$process_name" > /dev/null; then + echo "⏳ Stopping $service..." + if [ "$use_sudo" = "sudo" ]; then + sudo pkill -f "$process_name" + else + pkill -f "$process_name" + fi + sleep 2 + + if ! pgrep -f "$process_name" > /dev/null; then + echo "✅ $service stopped" + else + echo "⚠️ $service may still be running" + fi + else + echo "ℹ️ $service is not running" + fi +} + +# Stop Liquidsoap streaming +stop_service "Liquidsoap Streaming" "liquidsoap.*asteroid-radio.liq" + +# Stop Asteroid web server +stop_service "Asteroid Web Server" "asteroid" + +# Stop Icecast2 +stop_service "Icecast2" "icecast2" "sudo" + +echo "" +echo "🔇 Asteroid Radio services stopped" +echo "To restart, run: ./start-asteroid-radio.sh" diff --git a/stream-media.lisp b/stream-media.lisp new file mode 100644 index 0000000..c1145b6 --- /dev/null +++ b/stream-media.lisp @@ -0,0 +1,130 @@ +(in-package :asteroid) + +;; Music library scanning functions +(defun supported-audio-file-p (pathname) + "Check if file has a supported audio format extension" + (let ((extension (string-downcase (pathname-type pathname)))) + (member extension *supported-formats* :test #'string=))) + +(defun scan-directory-for-music (directory) + "Recursively scan directory for supported audio files" + (when (cl-fad:directory-exists-p directory) + (remove-if-not #'supported-audio-file-p + (cl-fad:list-directory directory :follow-symlinks nil)))) + +(defun extract-metadata-with-taglib (file-path) + "Extract metadata using taglib library" + (handler-case + (let* ((audio-file (audio-streams:open-audio-file (namestring file-path))) + (file-info (sb-posix:stat file-path)) + (format (string-downcase (pathname-type file-path)))) + (list :file-path (namestring file-path) + :format format + :size (sb-posix:stat-size file-info) + :modified (sb-posix:stat-mtime file-info) + :title (or (abstract-tag:title audio-file) (pathname-name file-path)) + :artist (or (abstract-tag:artist audio-file) "Unknown Artist") + :album (or (abstract-tag:album audio-file) "Unknown Album") + :duration (or (and (slot-exists-p audio-file 'audio-streams::duration) + (slot-boundp audio-file 'audio-streams::duration) + (round (audio-streams::duration audio-file))) + 0) + :bitrate (or (and (slot-exists-p audio-file 'audio-streams::bit-rate) + (slot-boundp audio-file 'audio-streams::bit-rate) + (round (audio-streams::bit-rate audio-file))) + 0))) + (error (e) + (format t "Warning: Could not extract metadata from ~a: ~a~%" file-path e) + ;; Fallback to basic file metadata + (extract-basic-metadata file-path)))) + +(defun extract-basic-metadata (file-path) + "Extract basic file metadata (fallback when taglib fails)" + (when (probe-file file-path) + (let ((file-info (sb-posix:stat file-path))) + (list :file-path (namestring file-path) + :format (string-downcase (pathname-type file-path)) + :size (sb-posix:stat-size file-info) + :modified (sb-posix:stat-mtime file-info) + :title (pathname-name file-path) + :artist "Unknown Artist" + :album "Unknown Album" + :duration 0 + :bitrate 0)))) + +(defun insert-track-to-database (metadata) + "Insert track metadata into database" + (db:insert "tracks" + (list (list "title" (getf metadata :title)) + (list "artist" (getf metadata :artist)) + (list "album" (getf metadata :album)) + (list "duration" (getf metadata :duration)) + (list "file-path" (getf metadata :file-path)) + (list "format" (getf metadata :format)) + (list "bitrate" (getf metadata :bitrate)) + (list "added-date" (local-time:timestamp-to-unix (local-time:now))) + (list "play-count" 0)))) + +(defun scan-music-library (&optional (directory *music-library-path*)) + "Scan music library directory and add tracks to database" + (format t "Scanning music library: ~a~%" directory) + (let ((audio-files (scan-directory-for-music directory)) + (added-count 0)) + (dolist (file audio-files) + (let ((metadata (extract-metadata-with-taglib file))) + (when metadata + (handler-case + (progn + (insert-track-to-database metadata) + (incf added-count) + (format t "Added: ~a~%" (getf metadata :file-path))) + (error (e) + (format t "Error adding ~a: ~a~%" file e)))))) + (format t "Library scan complete. Added ~a tracks.~%" added-count) + added-count)) + +;; Initialize music directory structure +(defun ensure-music-directories () + "Create music directory structure if it doesn't exist" + (let ((base-dir (merge-pathnames "music/" (asdf:system-source-directory :asteroid)))) + (ensure-directories-exist (merge-pathnames "library/" base-dir)) + (ensure-directories-exist (merge-pathnames "incoming/" base-dir)) + (ensure-directories-exist (merge-pathnames "temp/" base-dir)) + (format t "Music directories initialized at ~a~%" base-dir))) + +;; Simple file copy endpoint for manual uploads +(define-page copy-files #@"/admin/copy-files" () + "Copy files from incoming directory to library" + (handler-case + (let ((incoming-dir (merge-pathnames "music/incoming/" + (asdf:system-source-directory :asteroid))) + (library-dir (merge-pathnames "music/library/" + (asdf:system-source-directory :asteroid))) + (files-copied 0)) + (ensure-directories-exist incoming-dir) + (ensure-directories-exist library-dir) + + ;; Process all files in incoming directory + (dolist (file (directory (merge-pathnames "*.*" incoming-dir))) + (when (probe-file file) + (let* ((filename (file-namestring file)) + (file-extension (string-downcase (or (pathname-type file) ""))) + (target-path (merge-pathnames filename library-dir))) + (when (member file-extension *supported-formats* :test #'string=) + (alexandria:copy-file file target-path) + (delete-file file) + (incf files-copied) + ;; Extract metadata and add to database + (let ((metadata (extract-metadata-with-taglib target-path))) + (insert-track-to-database metadata)))))) + + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "success") + ("message" . ,(format nil "Copied ~d files to library" files-copied)) + ("files-copied" . ,files-copied)))) + (error (e) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . ,(format nil "Copy failed: ~a" e))))))) diff --git a/template/admin.chtml b/template/admin.chtml index afb3d19..eca9611 100644 --- a/template/admin.chtml +++ b/template/admin.chtml @@ -11,26 +11,322 @@
🟢 Running
+ + +🟢 Running
+🟢 Connected
+🔴 Not Running
+🔴 Not Running
+🟡 Not Connected
+To add your own MP3 files:
+/home/glenn/Projects/Code/asteroid/music/incoming/Supported formats: MP3, FLAC, OGG, WAV
+🔴 Not Running
+ +🔴 Not Running
+ +Total Tracks: 0
+Library Path: /music/library/
+Status: Stopped
+Current Track: None
+Position: 0s