Implement complete internet radio streaming system
- Add live streaming with Icecast2 and Liquidsoap integration - Fix track streaming endpoints with proper RADIANCE database queries - Implement music library management with metadata extraction - Add web player interface with HTML5 audio controls - Fix admin panel functionality for file management - Create playlist system for continuous radio broadcasting - Add live stream URL to web interface - Support MP3 streaming at 128kbps with proper audio processing - Enable network access for internal radio broadcasting - Add comprehensive README.org documentation - Create start/stop scripts for service management - Use secure random password for streaming authentication
This commit is contained in:
parent
9f1524da02
commit
cb1d6e5596
|
|
@ -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.
|
||||
|
|
@ -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/")
|
||||
|
|
@ -11,7 +11,6 @@
|
|||
:class "radiance:virtual-module"
|
||||
:depends-on (:radiance
|
||||
:r-clip
|
||||
:spinneret
|
||||
:cl-json
|
||||
:dexador
|
||||
:lass
|
||||
|
|
|
|||
182
asteroid.lisp
182
asteroid.lisp
|
|
@ -133,6 +133,43 @@
|
|||
(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)))))))
|
||||
|
||||
;; API Routes
|
||||
(define-page admin-scan-library #@"/admin/scan-library" ()
|
||||
"API endpoint to scan music library"
|
||||
|
|
@ -174,6 +211,71 @@
|
|||
`(("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)
|
||||
(plump:clear node)
|
||||
|
|
@ -198,6 +300,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)
|
||||
|
|
@ -221,7 +392,10 @@
|
|||
|
||||
(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"
|
||||
|
|
@ -230,9 +404,11 @@
|
|||
(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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -11,26 +11,322 @@
|
|||
<h1>🎛️ ADMIN DASHBOARD</h1>
|
||||
<div class="nav">
|
||||
<a href="/">← Back to Main</a>
|
||||
<a href="/player">Web Player</a>
|
||||
<a href="/player/">Web Player</a>
|
||||
</div>
|
||||
<div class="admin-grid">
|
||||
<div class="status-card">
|
||||
<h3>Server Status</h3>
|
||||
<p class="status-good" data-text="server-status">🟢 Running</p>
|
||||
|
||||
<!-- System Status -->
|
||||
<div class="admin-section">
|
||||
<h2>System Status</h2>
|
||||
<div class="admin-grid">
|
||||
<div class="status-card">
|
||||
<h3>Server Status</h3>
|
||||
<p class="status-good" data-text="server-status">🟢 Running</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Database Status</h3>
|
||||
<p class="status-good" data-text="database-status">🟢 Connected</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Liquidsoap Status</h3>
|
||||
<p class="status-error" data-text="liquidsoap-status">🔴 Not Running</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Icecast Status</h3>
|
||||
<p class="status-error" data-text="icecast-status">🔴 Not Running</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Database Status</h3>
|
||||
<p class="status-warning" data-text="database-status">🟡 Not Connected</p>
|
||||
</div>
|
||||
|
||||
<!-- Music Library Management -->
|
||||
<div class="admin-section">
|
||||
<h2>Music Library Management</h2>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="upload-section">
|
||||
<h3>Add Music Files</h3>
|
||||
<div class="upload-info">
|
||||
<p><strong>To add your own MP3 files:</strong></p>
|
||||
<ol>
|
||||
<li>Copy your MP3/FLAC/OGG/WAV files to: <code>/home/glenn/Projects/Code/asteroid/music/incoming/</code></li>
|
||||
<li>Click "Copy Files to Library" below</li>
|
||||
<li>Files will be moved to the library and added to the database</li>
|
||||
</ol>
|
||||
<p><em>Supported formats: MP3, FLAC, OGG, WAV</em></p>
|
||||
</div>
|
||||
<div class="upload-controls">
|
||||
<button id="copy-files" class="btn btn-success">📁 Copy Files to Library</button>
|
||||
<button id="open-incoming" class="btn btn-info">📂 Open Incoming Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Liquidsoap Status</h3>
|
||||
<p class="status-error" data-text="liquidsoap-status">🔴 Not Running</p>
|
||||
|
||||
<div class="admin-controls">
|
||||
<button id="scan-library" class="btn btn-primary">🔍 Scan Library</button>
|
||||
<button id="refresh-tracks" class="btn btn-secondary">🔄 Refresh Track List</button>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Icecast Status</h3>
|
||||
<p class="status-error" data-text="icecast-status">🔴 Not Running</p>
|
||||
|
||||
<div class="track-stats">
|
||||
<p>Total Tracks: <span id="track-count" data-text="track-count">0</span></p>
|
||||
<p>Library Path: <span data-text="library-path">/music/library/</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track Management -->
|
||||
<div class="admin-section">
|
||||
<h2>Track Management</h2>
|
||||
<div class="track-controls">
|
||||
<input type="text" id="track-search" placeholder="Search tracks..." class="search-input">
|
||||
<select id="sort-tracks" class="sort-select">
|
||||
<option value="title">Sort by Title</option>
|
||||
<option value="artist">Sort by Artist</option>
|
||||
<option value="album">Sort by Album</option>
|
||||
<option value="added-date">Sort by Date Added</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="tracks-container" class="tracks-list">
|
||||
<div class="loading">Loading tracks...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Control -->
|
||||
<div class="admin-section">
|
||||
<h2>Player Control</h2>
|
||||
<div class="player-controls">
|
||||
<button id="player-play" class="btn btn-success">▶️ Play</button>
|
||||
<button id="player-pause" class="btn btn-warning">⏸️ Pause</button>
|
||||
<button id="player-stop" class="btn btn-danger">⏹️ Stop</button>
|
||||
<button id="player-resume" class="btn btn-info">▶️ Resume</button>
|
||||
</div>
|
||||
|
||||
<div class="player-status">
|
||||
<p>Status: <span id="player-state">Stopped</span></p>
|
||||
<p>Current Track: <span id="current-track">None</span></p>
|
||||
<p>Position: <span id="current-position">0</span>s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Admin Dashboard JavaScript
|
||||
let tracks = [];
|
||||
let currentTrackId = null;
|
||||
|
||||
// Load tracks on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTracks();
|
||||
updatePlayerStatus();
|
||||
|
||||
// Setup event listeners
|
||||
document.getElementById('scan-library').addEventListener('click', scanLibrary);
|
||||
document.getElementById('refresh-tracks').addEventListener('click', loadTracks);
|
||||
document.getElementById('track-search').addEventListener('input', filterTracks);
|
||||
document.getElementById('sort-tracks').addEventListener('change', sortTracks);
|
||||
document.getElementById('copy-files').addEventListener('click', copyFiles);
|
||||
document.getElementById('open-incoming').addEventListener('click', openIncomingFolder);
|
||||
|
||||
// Player controls
|
||||
document.getElementById('player-play').addEventListener('click', () => playTrack(currentTrackId));
|
||||
document.getElementById('player-pause').addEventListener('click', pausePlayer);
|
||||
document.getElementById('player-stop').addEventListener('click', stopPlayer);
|
||||
document.getElementById('player-resume').addEventListener('click', resumePlayer);
|
||||
});
|
||||
|
||||
// Load tracks from API
|
||||
async function loadTracks() {
|
||||
try {
|
||||
const response = await fetch('/admin/tracks');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
tracks = data.tracks || [];
|
||||
document.getElementById('track-count').textContent = tracks.length;
|
||||
displayTracks(tracks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tracks:', error);
|
||||
document.getElementById('tracks-container').innerHTML = '<div class="error">Error loading tracks</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Display tracks in the UI
|
||||
function displayTracks(trackList) {
|
||||
const container = document.getElementById('tracks-container');
|
||||
|
||||
if (trackList.length === 0) {
|
||||
container.innerHTML = '<div class="no-tracks">No tracks found. Click "Scan Library" to add tracks.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tracksHtml = trackList.map(track => `
|
||||
<div class="track-item" data-track-id="${track.id}">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||
<div class="track-artist">${track.artist[0] || 'Unknown Artist'}</div>
|
||||
<div class="track-album">${track.album[0] || 'Unknown Album'}</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button onclick="playTrack(${track.id})" class="btn btn-sm btn-success">▶️ Play</button>
|
||||
<button onclick="streamTrack(${track.id})" class="btn btn-sm btn-info">🎵 Stream</button>
|
||||
<button onclick="deleteTrack(${track.id})" class="btn btn-sm btn-danger">🗑️ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = tracksHtml;
|
||||
}
|
||||
|
||||
// Scan music library
|
||||
async function scanLibrary() {
|
||||
const statusEl = document.getElementById('scan-status');
|
||||
const scanBtn = document.getElementById('scan-library');
|
||||
|
||||
statusEl.textContent = 'Scanning...';
|
||||
scanBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/scan-library', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
statusEl.textContent = `✅ Added ${data['tracks-added']} tracks`;
|
||||
loadTracks(); // Refresh track list
|
||||
} else {
|
||||
statusEl.textContent = '❌ Scan failed';
|
||||
}
|
||||
} catch (error) {
|
||||
statusEl.textContent = '❌ Scan error';
|
||||
console.error('Scan error:', error);
|
||||
} finally {
|
||||
scanBtn.disabled = false;
|
||||
setTimeout(() => statusEl.textContent = '', 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tracks based on search
|
||||
function filterTracks() {
|
||||
const query = document.getElementById('track-search').value.toLowerCase();
|
||||
const filtered = tracks.filter(track =>
|
||||
(track.title[0] || '').toLowerCase().includes(query) ||
|
||||
(track.artist[0] || '').toLowerCase().includes(query) ||
|
||||
(track.album[0] || '').toLowerCase().includes(query)
|
||||
);
|
||||
displayTracks(filtered);
|
||||
}
|
||||
|
||||
// Sort tracks
|
||||
function sortTracks() {
|
||||
const sortBy = document.getElementById('sort-tracks').value;
|
||||
const sorted = [...tracks].sort((a, b) => {
|
||||
const aVal = a[sortBy] ? a[sortBy][0] : '';
|
||||
const bVal = b[sortBy] ? b[sortBy][0] : '';
|
||||
return aVal.localeCompare(bVal);
|
||||
});
|
||||
displayTracks(sorted);
|
||||
}
|
||||
|
||||
// Player functions
|
||||
async function playTrack(trackId) {
|
||||
if (!trackId) {
|
||||
alert('Please select a track to play');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/play?track-id=${trackId}`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
currentTrackId = trackId;
|
||||
updatePlayerStatus();
|
||||
} else {
|
||||
alert('Error playing track: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Play error:', error);
|
||||
alert('Error playing track');
|
||||
}
|
||||
}
|
||||
|
||||
async function pausePlayer() {
|
||||
try {
|
||||
await fetch('/api/pause', { method: 'POST' });
|
||||
updatePlayerStatus();
|
||||
} catch (error) {
|
||||
console.error('Pause error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopPlayer() {
|
||||
try {
|
||||
await fetch('/api/stop', { method: 'POST' });
|
||||
currentTrackId = null;
|
||||
updatePlayerStatus();
|
||||
} catch (error) {
|
||||
console.error('Stop error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function resumePlayer() {
|
||||
try {
|
||||
await fetch('/api/resume', { method: 'POST' });
|
||||
updatePlayerStatus();
|
||||
} catch (error) {
|
||||
console.error('Resume error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlayerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/player-status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
const player = data.player;
|
||||
document.getElementById('player-state').textContent = player.state;
|
||||
document.getElementById('current-track').textContent = player['current-track'] || 'None';
|
||||
document.getElementById('current-position').textContent = player.position;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating player status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function streamTrack(trackId) {
|
||||
window.open(`/asteroid/tracks/${trackId}/stream`, '_blank');
|
||||
}
|
||||
|
||||
function deleteTrack(trackId) {
|
||||
if (confirm('Are you sure you want to delete this track?')) {
|
||||
// TODO: Implement track deletion API
|
||||
alert('Track deletion not yet implemented');
|
||||
}
|
||||
}
|
||||
|
||||
// Copy files from incoming to library
|
||||
async function copyFiles() {
|
||||
try {
|
||||
const response = await fetch('/admin/copy-files');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert(`${data.message}`);
|
||||
await loadTracks(); // Refresh track list
|
||||
} else {
|
||||
alert(`Error: ${data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying files:', error);
|
||||
alert('Failed to copy files');
|
||||
}
|
||||
}
|
||||
|
||||
// Open incoming folder (for convenience)
|
||||
function openIncomingFolder() {
|
||||
alert('Copy your MP3 files to: /home/glenn/Projects/Code/asteroid/music/incoming/\n\nThen click "Copy Files to Library" to add them to your music collection.');
|
||||
}
|
||||
|
||||
// Update player status every 5 seconds
|
||||
setInterval(updatePlayerStatus, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,21 @@
|
|||
</div>
|
||||
<div class="nav">
|
||||
<a href="/admin">Admin Dashboard</a>
|
||||
<a href="/player">Web Player</a>
|
||||
<a href="/player/">Web Player</a>
|
||||
<a href="http://localhost:8000/asteroid.mp3" target="_blank">🎵 Live Stream</a>
|
||||
<a href="/status">API Status</a>
|
||||
</div>
|
||||
|
||||
<div class="live-stream">
|
||||
<h2>🔴 LIVE STREAM</h2>
|
||||
<p><strong>Stream URL:</strong> <code>http://localhost:8000/asteroid.mp3</code></p>
|
||||
<p><strong>Format:</strong> MP3 128kbps Stereo</p>
|
||||
<p><strong>Status:</strong> <span style="color: #00ff00;">● BROADCASTING</span></p>
|
||||
<audio controls style="width: 100%; margin: 10px 0;">
|
||||
<source src="http://localhost:8000/asteroid.mp3" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Now Playing</h2>
|
||||
<p>Artist: <span data-text="now-playing-artist">The Void</span></p>
|
||||
|
|
|
|||
|
|
@ -13,21 +13,356 @@
|
|||
<a href="/">← Back to Main</a>
|
||||
<a href="/admin">Admin Dashboard</a>
|
||||
</div>
|
||||
<div class="player">
|
||||
<div class="now-playing">
|
||||
<h3>Now Playing</h3>
|
||||
<p>Artist: <span data-text="now-playing-artist">The Void</span></p>
|
||||
<p>Track: <span data-text="now-playing-track">Silence</span></p>
|
||||
<p>Album: <span data-text="now-playing-album">Startup Sounds</span></p>
|
||||
|
||||
<!-- Track Browser -->
|
||||
<div class="player-section">
|
||||
<h2>Track Library</h2>
|
||||
<div class="track-browser">
|
||||
<input type="text" id="search-tracks" placeholder="Search tracks..." class="search-input">
|
||||
<div id="track-list" class="track-list">
|
||||
<div class="loading">Loading tracks...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="player-controls">
|
||||
<button class="play-btn">▶️ Play</button>
|
||||
<button class="stop-btn">⏹️ Stop</button>
|
||||
<p>Stream: <span data-text="stream-url">http://localhost:8000/asteroid</span></p>
|
||||
<p>Quality: <span data-text="bitrate">128kbps MP3</span></p>
|
||||
<p>Status: <span data-text="player-status">Stopped</span></p>
|
||||
</div>
|
||||
|
||||
<!-- Audio Player Widget -->
|
||||
<div class="player-section">
|
||||
<h2>Audio Player</h2>
|
||||
<div class="audio-player">
|
||||
<div class="now-playing">
|
||||
<div class="track-art">🎵</div>
|
||||
<div class="track-details">
|
||||
<div class="track-title" id="current-title">No track selected</div>
|
||||
<div class="track-artist" id="current-artist">Unknown Artist</div>
|
||||
<div class="track-album" id="current-album">Unknown Album</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="audio-player" controls preload="none" style="width: 100%; margin: 20px 0;">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<div class="player-controls">
|
||||
<button id="prev-btn" class="btn btn-secondary">⏮️ Previous</button>
|
||||
<button id="play-pause-btn" class="btn btn-primary">▶️ Play</button>
|
||||
<button id="next-btn" class="btn btn-secondary">⏭️ Next</button>
|
||||
<button id="shuffle-btn" class="btn btn-info">🔀 Shuffle</button>
|
||||
<button id="repeat-btn" class="btn btn-warning">🔁 Repeat</button>
|
||||
</div>
|
||||
|
||||
<div class="player-info">
|
||||
<div class="time-display">
|
||||
<span id="current-time">0:00</span> / <span id="total-time">0:00</span>
|
||||
</div>
|
||||
<div class="volume-control">
|
||||
<label for="volume-slider">🔊</label>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" class="volume-slider">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Management -->
|
||||
<div class="player-section">
|
||||
<h2>Playlists</h2>
|
||||
<div class="playlist-controls">
|
||||
<input type="text" id="new-playlist-name" placeholder="New playlist name..." class="playlist-input">
|
||||
<button id="create-playlist" class="btn btn-success">➕ Create Playlist</button>
|
||||
</div>
|
||||
|
||||
<div class="playlist-list">
|
||||
<div id="playlists-container">
|
||||
<div class="no-playlists">No playlists created yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue -->
|
||||
<div class="player-section">
|
||||
<h2>Play Queue</h2>
|
||||
<div class="queue-controls">
|
||||
<button id="clear-queue" class="btn btn-danger">🗑️ Clear Queue</button>
|
||||
<button id="save-queue" class="btn btn-info">💾 Save as Playlist</button>
|
||||
</div>
|
||||
<div id="play-queue" class="play-queue">
|
||||
<div class="empty-queue">Queue is empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Web Player JavaScript
|
||||
let tracks = [];
|
||||
let currentTrack = null;
|
||||
let currentTrackIndex = -1;
|
||||
let playQueue = [];
|
||||
let isShuffled = false;
|
||||
let isRepeating = false;
|
||||
let audioPlayer = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
audioPlayer = document.getElementById('audio-player');
|
||||
loadTracks();
|
||||
setupEventListeners();
|
||||
updatePlayerDisplay();
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
// Search
|
||||
document.getElementById('search-tracks').addEventListener('input', filterTracks);
|
||||
|
||||
// Player controls
|
||||
document.getElementById('play-pause-btn').addEventListener('click', togglePlayPause);
|
||||
document.getElementById('prev-btn').addEventListener('click', playPrevious);
|
||||
document.getElementById('next-btn').addEventListener('click', playNext);
|
||||
document.getElementById('shuffle-btn').addEventListener('click', toggleShuffle);
|
||||
document.getElementById('repeat-btn').addEventListener('click', toggleRepeat);
|
||||
|
||||
// Volume control
|
||||
document.getElementById('volume-slider').addEventListener('input', updateVolume);
|
||||
|
||||
// Audio player events
|
||||
audioPlayer.addEventListener('loadedmetadata', updateTimeDisplay);
|
||||
audioPlayer.addEventListener('timeupdate', updateTimeDisplay);
|
||||
audioPlayer.addEventListener('ended', handleTrackEnd);
|
||||
audioPlayer.addEventListener('play', () => updatePlayButton('⏸️ Pause'));
|
||||
audioPlayer.addEventListener('pause', () => updatePlayButton('▶️ Play'));
|
||||
|
||||
// Playlist controls
|
||||
document.getElementById('create-playlist').addEventListener('click', createPlaylist);
|
||||
document.getElementById('clear-queue').addEventListener('click', clearQueue);
|
||||
document.getElementById('save-queue').addEventListener('click', saveQueueAsPlaylist);
|
||||
}
|
||||
|
||||
async function loadTracks() {
|
||||
try {
|
||||
const response = await fetch('/admin/tracks');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
tracks = data.tracks || [];
|
||||
displayTracks(tracks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tracks:', error);
|
||||
document.getElementById('track-list').innerHTML = '<div class="error">Error loading tracks</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayTracks(trackList) {
|
||||
const container = document.getElementById('track-list');
|
||||
|
||||
if (trackList.length === 0) {
|
||||
container.innerHTML = '<div class="no-tracks">No tracks found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tracksHtml = trackList.map((track, index) => `
|
||||
<div class="track-item" data-track-id="${track.id}" data-index="${index}">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist[0] || 'Unknown Artist'} • ${track.album[0] || 'Unknown Album'}</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button onclick="playTrack(${index})" class="btn btn-sm btn-success">▶️</button>
|
||||
<button onclick="addToQueue(${index})" class="btn btn-sm btn-info">➕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = tracksHtml;
|
||||
}
|
||||
|
||||
function filterTracks() {
|
||||
const query = document.getElementById('search-tracks').value.toLowerCase();
|
||||
const filtered = tracks.filter(track =>
|
||||
(track.title[0] || '').toLowerCase().includes(query) ||
|
||||
(track.artist[0] || '').toLowerCase().includes(query) ||
|
||||
(track.album[0] || '').toLowerCase().includes(query)
|
||||
);
|
||||
displayTracks(filtered);
|
||||
}
|
||||
|
||||
function playTrack(index) {
|
||||
if (index < 0 || index >= tracks.length) return;
|
||||
|
||||
currentTrack = tracks[index];
|
||||
currentTrackIndex = index;
|
||||
|
||||
// Load track into audio player
|
||||
audioPlayer.src = `/asteroid/tracks/${currentTrack.id}/stream`;
|
||||
audioPlayer.load();
|
||||
audioPlayer.play().catch(error => {
|
||||
console.error('Playback error:', error);
|
||||
alert('Error playing track. The track may not be available.');
|
||||
});
|
||||
|
||||
updatePlayerDisplay();
|
||||
|
||||
// Update server-side player state
|
||||
fetch(`/api/play?track-id=${currentTrack.id}`, { method: 'POST' })
|
||||
.catch(error => console.error('API update error:', error));
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!currentTrack) {
|
||||
alert('Please select a track to play');
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioPlayer.paused) {
|
||||
audioPlayer.play();
|
||||
} else {
|
||||
audioPlayer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function playPrevious() {
|
||||
if (playQueue.length > 0) {
|
||||
// Play from queue
|
||||
const prevIndex = Math.max(0, currentTrackIndex - 1);
|
||||
playTrack(prevIndex);
|
||||
} else {
|
||||
// Play previous track in library
|
||||
const prevIndex = currentTrackIndex > 0 ? currentTrackIndex - 1 : tracks.length - 1;
|
||||
playTrack(prevIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function playNext() {
|
||||
if (playQueue.length > 0) {
|
||||
// Play from queue
|
||||
const nextTrack = playQueue.shift();
|
||||
playTrack(tracks.findIndex(t => t.id === nextTrack.id));
|
||||
updateQueueDisplay();
|
||||
} else {
|
||||
// Play next track in library
|
||||
const nextIndex = isShuffled ?
|
||||
Math.floor(Math.random() * tracks.length) :
|
||||
(currentTrackIndex + 1) % tracks.length;
|
||||
playTrack(nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackEnd() {
|
||||
if (isRepeating) {
|
||||
audioPlayer.currentTime = 0;
|
||||
audioPlayer.play();
|
||||
} else {
|
||||
playNext();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
isShuffled = !isShuffled;
|
||||
const btn = document.getElementById('shuffle-btn');
|
||||
btn.textContent = isShuffled ? '🔀 Shuffle ON' : '🔀 Shuffle';
|
||||
btn.classList.toggle('active', isShuffled);
|
||||
}
|
||||
|
||||
function toggleRepeat() {
|
||||
isRepeating = !isRepeating;
|
||||
const btn = document.getElementById('repeat-btn');
|
||||
btn.textContent = isRepeating ? '🔁 Repeat ON' : '🔁 Repeat';
|
||||
btn.classList.toggle('active', isRepeating);
|
||||
}
|
||||
|
||||
function updateVolume() {
|
||||
const volume = document.getElementById('volume-slider').value / 100;
|
||||
audioPlayer.volume = volume;
|
||||
}
|
||||
|
||||
function updateTimeDisplay() {
|
||||
const current = formatTime(audioPlayer.currentTime);
|
||||
const total = formatTime(audioPlayer.duration);
|
||||
document.getElementById('current-time').textContent = current;
|
||||
document.getElementById('total-time').textContent = total;
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (isNaN(seconds)) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function updatePlayButton(text) {
|
||||
document.getElementById('play-pause-btn').textContent = text;
|
||||
}
|
||||
|
||||
function updatePlayerDisplay() {
|
||||
if (currentTrack) {
|
||||
document.getElementById('current-title').textContent = currentTrack.title[0] || 'Unknown Title';
|
||||
document.getElementById('current-artist').textContent = currentTrack.artist[0] || 'Unknown Artist';
|
||||
document.getElementById('current-album').textContent = currentTrack.album[0] || 'Unknown Album';
|
||||
}
|
||||
}
|
||||
|
||||
function addToQueue(index) {
|
||||
if (index < 0 || index >= tracks.length) return;
|
||||
|
||||
playQueue.push(tracks[index]);
|
||||
updateQueueDisplay();
|
||||
}
|
||||
|
||||
function updateQueueDisplay() {
|
||||
const container = document.getElementById('play-queue');
|
||||
|
||||
if (playQueue.length === 0) {
|
||||
container.innerHTML = '<div class="empty-queue">Queue is empty</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const queueHtml = playQueue.map((track, index) => `
|
||||
<div class="queue-item">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist[0] || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button onclick="removeFromQueue(${index})" class="btn btn-sm btn-danger">✖️</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = queueHtml;
|
||||
}
|
||||
|
||||
function removeFromQueue(index) {
|
||||
playQueue.splice(index, 1);
|
||||
updateQueueDisplay();
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
playQueue = [];
|
||||
updateQueueDisplay();
|
||||
}
|
||||
|
||||
function createPlaylist() {
|
||||
const name = document.getElementById('new-playlist-name').value.trim();
|
||||
if (!name) {
|
||||
alert('Please enter a playlist name');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement playlist creation API
|
||||
alert('Playlist creation not yet implemented');
|
||||
document.getElementById('new-playlist-name').value = '';
|
||||
}
|
||||
|
||||
function saveQueueAsPlaylist() {
|
||||
if (playQueue.length === 0) {
|
||||
alert('Queue is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = prompt('Enter playlist name:');
|
||||
if (name) {
|
||||
// TODO: Implement save queue as playlist
|
||||
alert('Save queue as playlist not yet implemented');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize volume
|
||||
updateVolume();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue