Compare commits
79 Commits
f095dc39c3
...
90bb9a1650
| Author | SHA1 | Date |
|---|---|---|
|
|
90bb9a1650 | |
|
|
0bb93c53a4 | |
|
|
bc36a00322 | |
|
|
637650a5ef | |
|
|
fd02e4c1d1 | |
|
|
1c85464a5f | |
|
|
f1eb43b325 | |
|
|
a458a85823 | |
|
|
ab3acf1279 | |
|
|
c4fd96289b | |
|
|
0930fc2c1c | |
|
|
a2ae329d54 | |
|
|
66e97aaf37 | |
|
|
a795680e99 | |
|
|
d8abd9661d | |
|
|
01f5806959 | |
|
|
74cd3625f3 | |
|
|
9721fbbc8a | |
|
|
b3fd00cb4d | |
|
|
4d0b54f7d6 | |
|
|
f3d012cbc6 | |
|
|
d0efc89e33 | |
|
|
136fa2fa74 | |
|
|
6506f7d153 | |
|
|
a7fe6a73c7 | |
|
|
fd7707eb74 | |
|
|
5f78213d92 | |
|
|
d4edb8bfec | |
|
|
b64d101f8a | |
|
|
70263fbfbc | |
|
|
91c77206d1 | |
|
|
8f1ce3f149 | |
|
|
5362c86f9f | |
|
|
4b8a3a064c | |
|
|
26c516c25d | |
|
|
b61e9c891a | |
|
|
a739edc16f | |
|
|
6e82688959 | |
|
|
925a624bda | |
|
|
da054c1ab9 | |
|
|
e126426acd | |
|
|
549ba8b38f | |
|
|
2b6bad3348 | |
|
|
dff2f52f7b | |
|
|
82785e1da1 | |
|
|
5bc6f27840 | |
|
|
5e33d2aafe | |
|
|
e0c1eac408 | |
|
|
a77b7768c4 | |
|
|
5fcb1a06d5 | |
|
|
19c984b238 | |
|
|
dde8027b5c | |
|
|
dff299923e | |
|
|
9ec7848b47 | |
|
|
707e7aba96 | |
|
|
efb96f950e | |
|
|
5d31763e85 | |
|
|
fa1de1c874 | |
|
|
f7bc30f18c | |
|
|
1b1445e25f | |
|
|
b31800a7db | |
|
|
8af85afe0e | |
|
|
ba13777206 | |
|
|
d12fde7248 | |
|
|
82071bb18d | |
|
|
d87c1be373 | |
|
|
d0649a611e | |
|
|
8589b774ed | |
|
|
c2e7cfe943 | |
|
|
bd1b993a03 | |
|
|
6edbb5754b | |
|
|
35f4c6516b | |
|
|
cd3c2347ec | |
|
|
14b6904cf5 | |
|
|
439da74bb3 | |
|
|
803555b8b1 | |
|
|
ab7a7c47b5 | |
|
|
b39b54adcb | |
|
|
24feeddfa8 |
|
|
@ -0,0 +1,8 @@
|
|||
docker/
|
||||
music/
|
||||
data/
|
||||
*.org
|
||||
docker-compose.yml
|
||||
Dockerfile*
|
||||
Makefile
|
||||
.git/
|
||||
|
|
@ -17,19 +17,6 @@
|
|||
*.wx32fsl
|
||||
/slime.lisp
|
||||
asteroid
|
||||
buildapp
|
||||
quicklisp-manifest.txt
|
||||
notes/
|
||||
run-asteroid.sh
|
||||
build-sbcl.sh
|
||||
|
||||
# Music files - don't commit audio files to repository
|
||||
*.mp3
|
||||
*.flac
|
||||
*.ogg
|
||||
*.wav
|
||||
*.m4a
|
||||
*.aac
|
||||
*.wma
|
||||
|
||||
# Docker music directory - keep folder but ignore music files
|
||||
|
|
@ -56,15 +43,6 @@ docker-compose.yml.backup.*
|
|||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
performance-logs/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Shell scripts (exclude from repository)
|
||||
*.sh
|
||||
# Exception: Docker utility scripts should be included
|
||||
!docker/start.sh
|
||||
!docker/stop.sh
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
FROM debian:bookworm-slim AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl openssl ca-certificates \
|
||||
git make sbcl rlwrap && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy asteroid source to container workdir
|
||||
COPY . .
|
||||
|
||||
# Download Quicklisp installer
|
||||
RUN curl -O https://beta.quicklisp.org/quicklisp.lisp
|
||||
|
||||
# Installs quicklisp and radiance
|
||||
RUN sbcl --eval "(load \"quicklisp.lisp\")" \
|
||||
--eval "(quicklisp-quickstart:install)" \
|
||||
--eval "(ql-dist:install-dist \"http://dist.shirakumo.org/shirakumo.txt\" :prompt nil)" \
|
||||
--eval "(ql:quickload :radiance)"
|
||||
|
||||
# Makes the project workdir known as a quicklisp project
|
||||
RUN mkdir -p $HOME/.config/common-lisp/source-registry.conf.d
|
||||
RUN echo '(:tree "/app/")' >> "$HOME/.config/common-lisp/source-registry.conf.d/projects.conf"
|
||||
|
||||
# Builds Asteroid binary
|
||||
RUN make
|
||||
|
||||
# Links binary to path
|
||||
ENV PATH="$PATH:/app"
|
||||
|
||||
# Adds radiance system configuration file
|
||||
COPY docker/radiance-default.conf.lisp $HOME/.config/radiance/default/radiance-core/radiance-core.conf.lisp
|
||||
|
||||
# Application
|
||||
EXPOSE 8080
|
||||
# Slynk server
|
||||
EXPOSE 4009
|
||||
|
||||
ENV ASTEROID_STREAM_URL=http://localhost:8000
|
||||
|
||||
CMD [ "asteroid" ]
|
||||
491
README.org
491
README.org
|
|
@ -1,35 +1,45 @@
|
|||
#+TITLE: Asteroid Radio - Internet Streaming Implementation
|
||||
#+AUTHOR: Database Implementation Branch
|
||||
#+DATE: 2025-09-11
|
||||
#+TITLE: Asteroid Radio - Internet Radio Streaming Platform
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* 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.
|
||||
Asteroid Radio is a complete internet radio streaming platform built with Common Lisp, featuring a hacker-themed terminal aesthetic. The project combines the Radiance web framework with Icecast/Liquidsoap streaming infrastructure to create a full-featured music streaming platform with live broadcasting capabilities.
|
||||
|
||||
** Project Links
|
||||
- *Repository*: https://github.com/fade/asteroid
|
||||
- *IRC*: #asteroid.music on irc.libera.chat
|
||||
- *Documentation*: See =docs/= directory for comprehensive guides
|
||||
|
||||
* Key Features
|
||||
|
||||
** Live Internet Radio Streaming
|
||||
- Continuous MP3 streaming at 128kbps stereo
|
||||
- Professional audio processing with crossfading and normalization
|
||||
- Multiple quality streams: 128kbps MP3, 96kbps AAC, 64kbps MP3
|
||||
- Professional audio processing with crossfading and ReplayGain normalization
|
||||
- Icecast2 streaming server integration
|
||||
- Liquidsoap audio pipeline for reliable broadcasting
|
||||
- Stream queue control for curated programming
|
||||
|
||||
** 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
|
||||
- Track search, filtering, sorting, and pagination
|
||||
- Recursive directory scanning
|
||||
|
||||
** Web Interface
|
||||
- RADIANCE framework with CLIP templating
|
||||
- Admin dashboard for library management
|
||||
- Web player with HTML5 audio controls
|
||||
- Admin dashboard for library and user management
|
||||
- Multiple player modes: inline, pop-out, and persistent frameset
|
||||
- Live stream integration with embedded player
|
||||
- Responsive design for desktop and mobile
|
||||
- Role-based access control (Admin/DJ/Listener)
|
||||
|
||||
** Network Broadcasting
|
||||
- WSL-compatible networking for internal network access
|
||||
- Dynamic stream URL detection for multi-environment support
|
||||
- Professional streaming URLs for media players
|
||||
- Multi-listener support via Icecast2
|
||||
- Docker-based deployment for easy setup
|
||||
|
||||
* Architecture Changes
|
||||
|
||||
|
|
@ -40,46 +50,82 @@ This branch implements a complete internet radio streaming system for Asteroid R
|
|||
- Database abstraction layer for track storage
|
||||
|
||||
** Streaming Stack
|
||||
- *Icecast2*: Streaming server (port 8000)
|
||||
- *Liquidsoap*: Audio processing and streaming pipeline
|
||||
- *Icecast2*: Streaming server (port 8000) - Docker containerized
|
||||
- *Liquidsoap*: Audio processing and streaming pipeline - Docker containerized
|
||||
- *RADIANCE*: Web server and API (port 8080)
|
||||
- *Database*: Track metadata and playlist storage
|
||||
- *PostgreSQL*: Database backend (configured, ready for migration)
|
||||
- *Docker Compose*: Container orchestration
|
||||
|
||||
** 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
|
||||
├── stream-control.lisp # Stream queue management
|
||||
├── user-management.lisp # User administration
|
||||
├── playlist-management.lisp # Playlist operations
|
||||
├── test-server.sh # Automated test suite
|
||||
├── docker/ # Docker infrastructure
|
||||
│ ├── docker-compose.yml # Container orchestration
|
||||
│ ├── asteroid-radio-docker.liq # Liquidsoap config
|
||||
│ ├── icecast.xml # Icecast configuration
|
||||
│ └── music/ # Music library mount
|
||||
├── template/ # CLIP HTML templates
|
||||
│ ├── front-page.chtml # Main page with live stream
|
||||
│ ├── admin.chtml # Admin dashboard
|
||||
│ └── player.chtml # Web player interface
|
||||
│ ├── player.chtml # Web player interface
|
||||
│ └── users.chtml # User management
|
||||
├── static/ # CSS and assets
|
||||
│ └── asteroid.lass # LASS stylesheet
|
||||
└── music/ # Music library
|
||||
├── incoming/ # Upload staging area
|
||||
└── library/ # Processed music files
|
||||
├── docs/ # Comprehensive documentation
|
||||
│ ├── README.org # Documentation index
|
||||
│ ├── PROJECT-OVERVIEW.org # Architecture overview
|
||||
│ ├── PROJECT-HISTORY.org # Development timeline
|
||||
│ ├── INSTALLATION.org # Setup guide
|
||||
│ └── ... # Additional guides
|
||||
└── music/ # Music library (local dev)
|
||||
#+END_SRC
|
||||
|
||||
* Track Upload Workflow
|
||||
* Quick Start
|
||||
|
||||
** 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
|
||||
** Docker Installation (Recommended)
|
||||
#+BEGIN_SRC bash
|
||||
# Clone repository
|
||||
git clone https://github.com/fade/asteroid
|
||||
cd asteroid/docker
|
||||
|
||||
** 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
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# Verify streams are working
|
||||
curl -I http://localhost:8000/asteroid.mp3
|
||||
curl -I http://localhost:8000/asteroid.aac
|
||||
curl -I http://localhost:8000/asteroid-low.mp3
|
||||
#+END_SRC
|
||||
|
||||
** Access Points
|
||||
- *Web Interface*: http://localhost:8080/asteroid/
|
||||
- *Admin Panel*: http://localhost:8080/asteroid/admin
|
||||
- *High Quality MP3*: http://localhost:8000/asteroid.mp3 (128kbps)
|
||||
- *High Quality AAC*: http://localhost:8000/asteroid.aac (96kbps)
|
||||
- *Low Quality MP3*: http://localhost:8000/asteroid-low.mp3 (64kbps)
|
||||
- *Icecast Admin*: http://localhost:8000/admin/ (admin/asteroid_admin_2024)
|
||||
|
||||
* Music Library Management
|
||||
|
||||
** Adding Music
|
||||
1. *Copy files*: Place MP3/FLAC files in =docker/music/= directory
|
||||
2. *Access admin panel*: Navigate to =http://localhost:8080/asteroid/admin=
|
||||
3. *Scan library*: Click "Scan Library" to index new tracks
|
||||
4. *Metadata extraction*: Track information automatically extracted
|
||||
5. *Stream queue*: Optionally add tracks to broadcast queue
|
||||
|
||||
** Library Scanning
|
||||
1. Recursive directory scanning of music folder
|
||||
2. Metadata extracted using taglib (title, artist, album, duration)
|
||||
3. Database records created with file paths and metadata
|
||||
4. Tracks immediately available for playback and streaming
|
||||
5. Supports nested folder structures
|
||||
|
||||
** Supported Formats
|
||||
- *MP3*: Primary format, best compatibility
|
||||
|
|
@ -90,216 +136,259 @@ asteroid/
|
|||
* Icecast2 Integration
|
||||
|
||||
** Configuration
|
||||
- *Server*: localhost:8000
|
||||
- *Mount point*: =/asteroid.mp3=
|
||||
- *Password*: =b3l0wz3r0= (configured in Liquidsoap)
|
||||
- *Format*: MP3 128kbps stereo
|
||||
- *Server*: localhost:8000 (Docker container)
|
||||
- *Mount points*: =/asteroid.mp3=, =/asteroid.aac=, =/asteroid-low.mp3=
|
||||
- *Password*: =H1tn31EhsyLrfRmo= (configured in Docker setup)
|
||||
- *Formats*: MP3 128kbps, AAC 96kbps, MP3 64kbps
|
||||
|
||||
** Docker Setup
|
||||
Icecast2 runs in a Docker container - no manual installation needed.
|
||||
|
||||
** Installation (Ubuntu/Debian)
|
||||
#+BEGIN_SRC bash
|
||||
sudo apt update
|
||||
sudo apt install icecast2
|
||||
sudo systemctl enable icecast2
|
||||
sudo systemctl start icecast2
|
||||
# Managed via docker-compose
|
||||
cd docker
|
||||
docker compose up -d icecast
|
||||
#+END_SRC
|
||||
|
||||
** Stream Access
|
||||
- *Direct URL*: =http://[IP]:8000/asteroid.mp3=
|
||||
- *Admin interface*: =http://[IP]:8000/admin/=
|
||||
- *Statistics*: =http://[IP]:8000/status.xsl=
|
||||
- *High Quality MP3*: =http://localhost:8000/asteroid.mp3= (128kbps)
|
||||
- *High Quality AAC*: =http://localhost:8000/asteroid.aac= (96kbps)
|
||||
- *Low Quality MP3*: =http://localhost:8000/asteroid-low.mp3= (64kbps)
|
||||
- *Admin interface*: =http://localhost:8000/admin/= (admin/asteroid_admin_2024)
|
||||
- *Statistics*: =http://localhost:8000/status.xsl=
|
||||
|
||||
* Liquidsoap Integration
|
||||
|
||||
** Configuration File: =asteroid-radio.liq=
|
||||
#+BEGIN_SRC liquidsoap
|
||||
#!/usr/bin/liquidsoap
|
||||
** Docker Configuration
|
||||
Liquidsoap runs in a Docker container with configuration in =docker/asteroid-radio-docker.liq=
|
||||
|
||||
# Set log level for debugging
|
||||
settings.log.level := 4
|
||||
** Key Features
|
||||
- *Multiple outputs*: Generates 3 simultaneous streams (MP3 128k, AAC 96k, MP3 64k)
|
||||
- *Audio processing*: Crossfading, normalization, ReplayGain
|
||||
- *Stream queue*: Reads from M3U playlist for curated programming
|
||||
- *Telnet control*: Remote control interface on port 1234
|
||||
- *Metadata*: Broadcasts track information to listeners
|
||||
|
||||
# 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)
|
||||
** Management
|
||||
#+BEGIN_SRC bash
|
||||
sudo apt update
|
||||
sudo apt install liquidsoap
|
||||
# Start Liquidsoap container
|
||||
cd docker
|
||||
docker compose up -d liquidsoap
|
||||
|
||||
# View logs
|
||||
docker compose logs -f liquidsoap
|
||||
|
||||
# Restart streaming
|
||||
docker compose restart 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
|
||||
** Telnet Control
|
||||
#+BEGIN_SRC bash
|
||||
# Launch all services
|
||||
./start-asteroid-radio.sh
|
||||
# Connect to Liquidsoap
|
||||
telnet localhost 1234
|
||||
|
||||
# Or use netcat for scripting
|
||||
echo "request.queue" | nc localhost 1234
|
||||
echo "request.skip" | nc localhost 1234
|
||||
#+END_SRC
|
||||
|
||||
** Stopping the Radio Station
|
||||
#+BEGIN_SRC bash
|
||||
# Stop all services
|
||||
./stop-asteroid-radio.sh
|
||||
#+END_SRC
|
||||
* User Management
|
||||
|
||||
** 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
|
||||
** Roles
|
||||
- *Admin*: Full system access, user management, stream control
|
||||
- *DJ*: Content management, playlist creation, library access
|
||||
- *Listener*: Basic playback and personal playlists
|
||||
|
||||
** 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
|
||||
** Default Credentials
|
||||
- Username: =admin=
|
||||
- Password: =asteroid123=
|
||||
- ⚠️ Change default password after first login
|
||||
|
||||
** User Administration
|
||||
- Create/manage users via admin panel
|
||||
- Role-based access control
|
||||
- User profiles and preferences
|
||||
- Session management
|
||||
|
||||
* Player Modes
|
||||
|
||||
** Inline Player
|
||||
- Embedded in web pages
|
||||
- Standard HTML5 audio controls
|
||||
- Queue management
|
||||
|
||||
** Pop-Out Player
|
||||
- Standalone player window
|
||||
- Independent from main browser window
|
||||
- Persistent across page navigation
|
||||
|
||||
** Frameset Player
|
||||
- Bottom-frame persistent player
|
||||
- Audio continues during site navigation
|
||||
- Seamless listening experience
|
||||
|
||||
* API Endpoints
|
||||
|
||||
Asteroid Radio provides a comprehensive REST API with 15+ endpoints.
|
||||
|
||||
** Status & Authentication
|
||||
- =GET /api/asteroid/status= - Server status
|
||||
- =GET /api/asteroid/auth-status= - Authentication status
|
||||
- =GET /api/asteroid/icecast-status= - Streaming status
|
||||
|
||||
** 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
|
||||
- =GET /api/asteroid/tracks= - List all tracks
|
||||
- =GET /api/asteroid/admin/tracks= - Admin track listing
|
||||
- =POST /api/asteroid/admin/scan-library= - Scan music library
|
||||
|
||||
** 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
|
||||
- =GET /api/asteroid/player/status= - Player status
|
||||
- =POST /api/asteroid/player/play= - Play track
|
||||
- =POST /api/asteroid/player/pause= - Pause playback
|
||||
- =POST /api/asteroid/player/stop= - Stop playback
|
||||
- =POST /api/asteroid/player/resume= - Resume playback
|
||||
|
||||
** 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
|
||||
** Playlist Management
|
||||
- =GET /api/asteroid/playlists= - List user playlists
|
||||
- =POST /api/asteroid/playlists/create= - Create playlist
|
||||
- =GET /api/asteroid/playlists/get= - Get playlist details
|
||||
- =POST /api/asteroid/playlists/add-track= - Add track to playlist
|
||||
|
||||
* Database Schema
|
||||
** Stream Queue Control (Admin)
|
||||
- =GET /api/asteroid/stream/queue= - Get broadcast queue
|
||||
- =POST /api/asteroid/stream/queue/add= - Add track to queue
|
||||
- =POST /api/asteroid/stream/queue/remove= - Remove from queue
|
||||
- =POST /api/asteroid/stream/queue/clear= - Clear queue
|
||||
|
||||
** 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
|
||||
See =docs/API-ENDPOINTS.org= for complete API documentation.
|
||||
|
||||
** Playlists Collection (Future)
|
||||
#+BEGIN_SRC lisp
|
||||
(db:create "playlists" '((name :text)
|
||||
(description :text)
|
||||
(created-date :integer)
|
||||
(track-ids :text)))
|
||||
#+END_SRC
|
||||
* Database
|
||||
|
||||
** Current: Radiance DB
|
||||
- File-based database abstraction
|
||||
- Tracks, users, playlists, sessions
|
||||
- Suitable for development and small deployments
|
||||
|
||||
** PostgreSQL (Configured)
|
||||
- Docker container ready
|
||||
- Full schema defined
|
||||
- Migration pending
|
||||
- See =docs/POSTGRESQL-SETUP.org= for details
|
||||
|
||||
* Documentation
|
||||
|
||||
Comprehensive documentation available in the =docs/= directory:
|
||||
|
||||
- *README.org* - Documentation index
|
||||
- *PROJECT-OVERVIEW.org* - Architecture and features
|
||||
- *PROJECT-HISTORY.org* - Development timeline and milestones
|
||||
- *INSTALLATION.org* - Complete installation guide
|
||||
- *DEVELOPMENT.org* - Developer setup and guidelines
|
||||
- *DOCKER-STREAMING.org* - Docker streaming infrastructure
|
||||
- *API-ENDPOINTS.org* - REST API reference
|
||||
- *STREAM-CONTROL.org* - Stream queue management
|
||||
- *USER-MANAGEMENT-SYSTEM.org* - User administration
|
||||
- *PLAYLIST-SYSTEM.org* - Playlist functionality
|
||||
- *TESTING.org* - Automated testing guide
|
||||
- *POSTGRESQL-SETUP.org* - Database setup
|
||||
|
||||
* 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
|
||||
** Lisp Dependencies
|
||||
- =radiance= - Web framework
|
||||
- =r-clip= - CLIP templating
|
||||
- =lass= - CSS preprocessing
|
||||
- =cl-json= - JSON handling
|
||||
- =alexandria= - Common Lisp utilities
|
||||
- =local-time= - Time handling
|
||||
- =taglib= - Audio metadata extraction
|
||||
|
||||
** System Dependencies
|
||||
- =icecast2= - Streaming server
|
||||
- =liquidsoap= - Audio processing
|
||||
- =taglib= - Metadata extraction (via audio-streams)
|
||||
** System Dependencies (Docker)
|
||||
- Docker Engine 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- All streaming components containerized
|
||||
|
||||
* Development Notes
|
||||
* Testing
|
||||
|
||||
** RADIANCE Configuration
|
||||
- Domain: "asteroid"
|
||||
- Routes use =#@= syntax for URL patterns
|
||||
- Database abstraction via =db:= functions
|
||||
- CLIP templates with =data-text= attributes
|
||||
** Automated Test Suite
|
||||
#+BEGIN_SRC bash
|
||||
# Run comprehensive tests
|
||||
./test-server.sh
|
||||
|
||||
** 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
|
||||
# Verbose mode
|
||||
./test-server.sh -v
|
||||
#+END_SRC
|
||||
|
||||
** Streaming Considerations
|
||||
- MP3 files with spaces in names require playlist.m3u approach
|
||||
- Liquidsoap fallback prevents stream silence
|
||||
- Icecast2 mount points must match Liquidsoap configuration
|
||||
** Test Coverage
|
||||
- 25+ automated tests
|
||||
- API endpoint validation
|
||||
- HTML page rendering
|
||||
- Static file serving
|
||||
- JSON response format
|
||||
- Authentication flows
|
||||
|
||||
* Future Enhancements
|
||||
* Contributing
|
||||
|
||||
** 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
|
||||
** Development Workflow
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run test suite
|
||||
5. Submit pull request
|
||||
|
||||
** Technical Improvements
|
||||
- WebSocket integration for real-time updates
|
||||
- Advanced audio processing options
|
||||
- Multi-bitrate streaming support
|
||||
- Mobile-responsive interface enhancements
|
||||
** Community
|
||||
- *IRC*: #asteroid.music on irc.libera.chat
|
||||
- *Issues*: GitHub issue tracker
|
||||
- *Discussions*: GitHub discussions
|
||||
|
||||
** Core Team
|
||||
- Brian O'Reilly (Fade) - Project founder
|
||||
- Glenn Thompson (glenneth) - Core developer
|
||||
- Luis Pereira - UI/UX
|
||||
|
||||
* 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
|
||||
** Docker Issues
|
||||
#+BEGIN_SRC bash
|
||||
# Check container status
|
||||
docker compose ps
|
||||
|
||||
** 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
|
||||
# View logs
|
||||
docker compose logs icecast
|
||||
docker compose logs liquidsoap
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
#+END_SRC
|
||||
|
||||
** Stream Not Playing
|
||||
- Verify containers are running
|
||||
- Check music files exist in =docker/music/=
|
||||
- Test stream URLs with curl
|
||||
- Review Liquidsoap logs
|
||||
|
||||
** Database Issues
|
||||
- Check Radiance DB file permissions
|
||||
- Verify database collections exist
|
||||
- Review application logs
|
||||
|
||||
For detailed troubleshooting, see documentation in =docs/= directory.
|
||||
|
||||
* License
|
||||
|
||||
This implementation maintains compatibility with the original Asteroid Radio project license while adding comprehensive streaming capabilities for internet radio broadcasting.
|
||||
See LICENSE file for details.
|
||||
|
||||
* Acknowledgments
|
||||
|
||||
Built with:
|
||||
- Common Lisp (SBCL)
|
||||
- Radiance web framework
|
||||
- Icecast2 streaming server
|
||||
- Liquidsoap audio processing
|
||||
- Docker containerization
|
||||
|
||||
Special thanks to all contributors and the Common Lisp community.
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-10-26*
|
||||
|
|
|
|||
92
TODO.org
92
TODO.org
|
|
@ -1,50 +1,66 @@
|
|||
* Rundown to Launch. Still to do:
|
||||
|
||||
** Server runtime configuration [0/1]
|
||||
* Setup asteroid.radio server at Hetzner [7/7]
|
||||
- [X] Provision a VPS
|
||||
- [X] Firewall
|
||||
- [X] Install user utilities
|
||||
- [X] Install base toolchain
|
||||
- [X] Install lisp
|
||||
- [X] Set up DNS
|
||||
- [X] Create user accounts
|
||||
|
||||
* Server runtime configuration [0/1]
|
||||
- [ ] parameterize all configuration for runtime loading [0/2]
|
||||
- [ ] strip hard coded configurations out of the system
|
||||
- [ ] add configuration template file to the project
|
||||
|
||||
** [ ] Database [0/2]
|
||||
- [ ] PostgresQL [0/3]
|
||||
- [ ] Add a postgresql docker image to our docker-compose file.
|
||||
** [ ] Database [0/1]
|
||||
- [-] PostgresQL [1/3]
|
||||
- [X] Add a postgresql docker image to our docker-compose file.
|
||||
- [ ] Configure radiance for postres.
|
||||
- [ ] Migrate all schema to new database.
|
||||
|
||||
** [ ] Templates: move our template hyrdration into the Clip machinery [0/4]
|
||||
- [ ] Admin Dashboard [0/2]
|
||||
- [ ] System Status [0/4]
|
||||
- [ ] Server Status
|
||||
- [ ] Database Status
|
||||
- [ ] Liquidsoap Status
|
||||
- [ ] Icecast Status
|
||||
** [X] Page Flow [2/2] ✅ COMPLETE
|
||||
- [X] When a user logs in, their user profile page should become the
|
||||
root node of the app in their view.
|
||||
- [X] When the admin user logs in, their view should become the admin
|
||||
profile page which should have panels for adminstering various
|
||||
aspects of the station.
|
||||
note: Front-page conditional elements working correctly - nav links
|
||||
display based on authentication status and user role (Profile/Admin/Logout
|
||||
for logged-in users, Login/Register for anonymous users).
|
||||
|
||||
- [ ] Music Library Management [0/2]
|
||||
- [ ] Add Music Files
|
||||
- [ ] Track Management
|
||||
This data needs to be paginated in some way, because the list
|
||||
becomes very long.
|
||||
- [ ] Player Control
|
||||
** [X] Templates: move our template hyrdration into the Clip machinery [4/4] ✅ COMPLETE
|
||||
- [X] Admin Dashboard [2/2]
|
||||
- [X] System Status [4/4]
|
||||
- [X] Server Status (Shows 🟢 Running)
|
||||
- [X] Database Status (Shows connection status)
|
||||
- [X] Liquidsoap Status (Checks Docker container)
|
||||
- [X] Icecast Status (Checks Docker container)
|
||||
|
||||
- [X] Music Library Management [3/3]
|
||||
- [X] Add Music Files (Upload and scan working)
|
||||
- [X] Track Management (Pagination complete - 20 tracks per page, 4 pages total)
|
||||
Pagination implemented with configurable items per page (10/20/50/100).
|
||||
- [X] Player Control (Play/pause/stop working with HTML5 audio)
|
||||
play/pause/edit &etc
|
||||
- [ ] User Management
|
||||
This should be its own page
|
||||
- [X] User Management (Moved to separate /admin/users page)
|
||||
|
||||
|
||||
|
||||
- [ ] Live Stream
|
||||
- [ ] Now Playing
|
||||
- [ ] Front Page [0/3]
|
||||
- [ ] Station Status
|
||||
- [ ] Live Stream
|
||||
- [ ] Now Playing
|
||||
Now Playing is currently broken on every page. I think this is in
|
||||
the javascript supporting the feature. Fix here, fix everywhere.
|
||||
- [ ] Web Player [0/6]
|
||||
- [ ] Live Radio Stream
|
||||
- [ ] Now Playing
|
||||
this currently has a bug where the Now Playing: info card is
|
||||
soing raw HTML which may or may not be coming from liquidSoap. Investigate
|
||||
- [ ] Personal Track Library
|
||||
- [ ] Audio Player
|
||||
- [ ] Playlists
|
||||
- [ ] Play Queue
|
||||
- [X] Live Stream
|
||||
- [X] Now Playing (Working correctly - displays artist and track)
|
||||
- [X] Front Page [3/3]
|
||||
- [X] Station Status (Shows live status, listeners, quality)
|
||||
- [X] Live Stream (Green indicator, quality selector working)
|
||||
- [X] Now Playing (Updates every 10s from Icecast, no HTML bugs)
|
||||
- [X] Web Player [5/6] ⚠️ MOSTLY COMPLETE (Playlists limited by database)
|
||||
- [X] Live Radio Stream (Working with quality selector)
|
||||
- [X] Now Playing (Updates correctly from Icecast)
|
||||
- [X] Personal Track Library (Pagination: 20 tracks/page, search working)
|
||||
- [X] Audio Player (Full controls: play/pause/prev/next/shuffle/repeat/volume)
|
||||
- [ ] Playlists (PARTIAL - Can create/view, but cannot save/load tracks - requires PostgreSQL)
|
||||
- [X] Create empty playlists
|
||||
- [X] View playlists
|
||||
- [ ] Save queue as playlist (tracks don't persist - db:update fails)
|
||||
- [ ] Load playlists (playlists are empty - no tracks saved)
|
||||
- [ ] Edit playlists (requires PostgreSQL)
|
||||
- [X] Play Queue (Add tracks, clear queue - save as playlist blocked by database)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Asteroid Radio Performance Analysis Tool
|
||||
Generates graphs and reports from performance test data
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
import glob
|
||||
import os
|
||||
from datetime import datetime
|
||||
import numpy as np
|
||||
|
||||
# Set up plotting style
|
||||
plt.style.use('dark_background')
|
||||
sns.set_palette("husl")
|
||||
|
||||
def load_performance_data():
|
||||
"""Load all CSV performance data files"""
|
||||
csv_files = glob.glob('performance-logs/*_data_*.csv')
|
||||
data_frames = {}
|
||||
|
||||
for file in csv_files:
|
||||
# Extract test type from filename
|
||||
filename = os.path.basename(file)
|
||||
if 'aac' in filename:
|
||||
test_type = 'AAC 96kbps'
|
||||
elif 'mp3-high' in filename:
|
||||
test_type = 'MP3 128kbps'
|
||||
elif 'mp3-low' in filename:
|
||||
test_type = 'MP3 64kbps'
|
||||
else:
|
||||
test_type = filename.split('_')[1]
|
||||
|
||||
try:
|
||||
df = pd.read_csv(file)
|
||||
df['test_type'] = test_type
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
||||
data_frames[test_type] = df
|
||||
print(f"✅ Loaded {len(df)} records from {test_type} test")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading {file}: {e}")
|
||||
|
||||
return data_frames
|
||||
|
||||
def create_performance_dashboard(data_frames):
|
||||
"""Create comprehensive performance dashboard"""
|
||||
|
||||
# Combine all data
|
||||
all_data = pd.concat(data_frames.values(), ignore_index=True)
|
||||
|
||||
# Create figure with subplots
|
||||
fig, axes = plt.subplots(2, 3, figsize=(20, 12))
|
||||
fig.suptitle('🎵 Asteroid Radio Performance Analysis Dashboard', fontsize=16, y=0.98)
|
||||
|
||||
# 1. CPU Usage Over Time (Asteroid App)
|
||||
ax1 = axes[0, 0]
|
||||
for test_type, df in data_frames.items():
|
||||
if 'asteroid_cpu' in df.columns:
|
||||
ax1.plot(df.index, df['asteroid_cpu'], label=test_type, linewidth=2)
|
||||
ax1.set_title('Asteroid App CPU Usage Over Time')
|
||||
ax1.set_xlabel('Time (samples)')
|
||||
ax1.set_ylabel('CPU %')
|
||||
ax1.legend()
|
||||
ax1.grid(True, alpha=0.3)
|
||||
|
||||
# 2. Memory Usage Over Time (Asteroid App)
|
||||
ax2 = axes[0, 1]
|
||||
for test_type, df in data_frames.items():
|
||||
if 'asteroid_mem_mb' in df.columns:
|
||||
ax2.plot(df.index, df['asteroid_mem_mb'], label=test_type, linewidth=2)
|
||||
ax2.set_title('Asteroid App Memory Usage Over Time')
|
||||
ax2.set_xlabel('Time (samples)')
|
||||
ax2.set_ylabel('Memory (MB)')
|
||||
ax2.legend()
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
# 3. Docker Container CPU Usage
|
||||
ax3 = axes[0, 2]
|
||||
for test_type, df in data_frames.items():
|
||||
if 'icecast_cpu' in df.columns and 'liquidsoap_cpu' in df.columns:
|
||||
ax3.plot(df.index, df['icecast_cpu'], label=f'{test_type} - Icecast', linestyle='--', alpha=0.7)
|
||||
ax3.plot(df.index, df['liquidsoap_cpu'], label=f'{test_type} - Liquidsoap', linestyle='-', alpha=0.9)
|
||||
ax3.set_title('Docker Container CPU Usage')
|
||||
ax3.set_xlabel('Time (samples)')
|
||||
ax3.set_ylabel('CPU %')
|
||||
ax3.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
|
||||
ax3.grid(True, alpha=0.3)
|
||||
|
||||
# 4. System Memory Usage
|
||||
ax4 = axes[1, 0]
|
||||
for test_type, df in data_frames.items():
|
||||
if 'system_mem_used_gb' in df.columns and 'system_mem_total_gb' in df.columns:
|
||||
memory_percent = (df['system_mem_used_gb'] / df['system_mem_total_gb']) * 100
|
||||
ax4.plot(df.index, memory_percent, label=test_type, linewidth=2)
|
||||
ax4.set_title('System Memory Usage')
|
||||
ax4.set_xlabel('Time (samples)')
|
||||
ax4.set_ylabel('Memory Usage %')
|
||||
ax4.legend()
|
||||
ax4.grid(True, alpha=0.3)
|
||||
|
||||
# 5. Average Performance Comparison
|
||||
ax5 = axes[1, 1]
|
||||
metrics = ['cpu_percent', 'memory_mb', 'stream_response_ms', 'web_response_ms']
|
||||
test_types = list(data_frames.keys())
|
||||
|
||||
performance_summary = {}
|
||||
for test_type, df in data_frames.items():
|
||||
performance_summary[test_type] = {
|
||||
'Asteroid CPU (%)': df['asteroid_cpu'].mean() if 'asteroid_cpu' in df.columns else 0,
|
||||
'Asteroid Mem (MB)': df['asteroid_mem_mb'].mean() if 'asteroid_mem_mb' in df.columns else 0,
|
||||
'Icecast CPU (%)': df['icecast_cpu'].mean() if 'icecast_cpu' in df.columns else 0,
|
||||
'Liquidsoap CPU (%)': df['liquidsoap_cpu'].mean() if 'liquidsoap_cpu' in df.columns else 0
|
||||
}
|
||||
|
||||
summary_df = pd.DataFrame(performance_summary).T
|
||||
summary_df.plot(kind='bar', ax=ax5)
|
||||
ax5.set_title('Average Performance Metrics')
|
||||
ax5.set_ylabel('Value')
|
||||
ax5.tick_params(axis='x', rotation=45)
|
||||
ax5.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
|
||||
|
||||
# 6. CPU Load Distribution
|
||||
ax6 = axes[1, 2]
|
||||
if 'asteroid_cpu' in all_data.columns:
|
||||
# Create boxplot data manually since pandas boxplot by group is tricky
|
||||
cpu_data = []
|
||||
labels = []
|
||||
for test_type, df in data_frames.items():
|
||||
if 'asteroid_cpu' in df.columns:
|
||||
cpu_data.append(df['asteroid_cpu'].values)
|
||||
labels.append(test_type.replace(' ', '\n'))
|
||||
|
||||
if cpu_data:
|
||||
ax6.boxplot(cpu_data, labels=labels)
|
||||
ax6.set_title('Asteroid CPU Load Distribution')
|
||||
ax6.set_xlabel('Stream Type')
|
||||
ax6.set_ylabel('CPU %')
|
||||
ax6.tick_params(axis='x', rotation=0)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig('performance-logs/asteroid_performance_dashboard.png', dpi=300, bbox_inches='tight')
|
||||
print("📊 Dashboard saved as: performance-logs/asteroid_performance_dashboard.png")
|
||||
|
||||
return fig
|
||||
|
||||
def generate_performance_report(data_frames):
|
||||
"""Generate detailed performance report"""
|
||||
|
||||
report = []
|
||||
report.append("🎵 ASTEROID RADIO PERFORMANCE ANALYSIS REPORT")
|
||||
report.append("=" * 50)
|
||||
report.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
report.append("")
|
||||
|
||||
for test_type, df in data_frames.items():
|
||||
report.append(f"📡 {test_type} Stream Analysis:")
|
||||
report.append("-" * 30)
|
||||
|
||||
if 'asteroid_cpu' in df.columns:
|
||||
cpu_stats = df['asteroid_cpu'].describe()
|
||||
report.append(f" Asteroid App CPU:")
|
||||
report.append(f" Average: {cpu_stats['mean']:.1f}%")
|
||||
report.append(f" Peak: {cpu_stats['max']:.1f}%")
|
||||
report.append(f" Minimum: {cpu_stats['min']:.1f}%")
|
||||
|
||||
if 'asteroid_mem_mb' in df.columns:
|
||||
mem_stats = df['asteroid_mem_mb'].describe()
|
||||
report.append(f" Asteroid App Memory:")
|
||||
report.append(f" Average: {mem_stats['mean']:.1f} MB")
|
||||
report.append(f" Peak: {mem_stats['max']:.1f} MB")
|
||||
report.append(f" Minimum: {mem_stats['min']:.1f} MB")
|
||||
|
||||
if 'icecast_cpu' in df.columns:
|
||||
icecast_stats = df['icecast_cpu'].describe()
|
||||
report.append(f" Icecast CPU:")
|
||||
report.append(f" Average: {icecast_stats['mean']:.2f}%")
|
||||
report.append(f" Peak: {icecast_stats['max']:.2f}%")
|
||||
|
||||
if 'liquidsoap_cpu' in df.columns:
|
||||
liquidsoap_stats = df['liquidsoap_cpu'].describe()
|
||||
report.append(f" Liquidsoap CPU:")
|
||||
report.append(f" Average: {liquidsoap_stats['mean']:.1f}%")
|
||||
report.append(f" Peak: {liquidsoap_stats['max']:.1f}%")
|
||||
|
||||
if 'stream_response_ms' in df.columns:
|
||||
stream_stats = df['stream_response_ms'].dropna().describe()
|
||||
if len(stream_stats) > 0:
|
||||
report.append(f" Stream Response:")
|
||||
report.append(f" Average: {stream_stats['mean']:.1f} ms")
|
||||
report.append(f" 95th percentile: {stream_stats.quantile(0.95):.1f} ms")
|
||||
|
||||
if 'web_response_ms' in df.columns:
|
||||
web_stats = df['web_response_ms'].dropna().describe()
|
||||
if len(web_stats) > 0:
|
||||
report.append(f" Web Response:")
|
||||
report.append(f" Average: {web_stats['mean']:.1f} ms")
|
||||
report.append(f" 95th percentile: {web_stats.quantile(0.95):.1f} ms")
|
||||
|
||||
report.append("")
|
||||
|
||||
# Performance recommendations
|
||||
report.append("🎯 PERFORMANCE RECOMMENDATIONS:")
|
||||
report.append("-" * 30)
|
||||
|
||||
# Find best performing stream
|
||||
avg_cpu = {}
|
||||
for test_type, df in data_frames.items():
|
||||
if 'asteroid_cpu' in df.columns:
|
||||
avg_cpu[test_type] = df['asteroid_cpu'].mean()
|
||||
|
||||
if avg_cpu:
|
||||
best_stream = min(avg_cpu, key=avg_cpu.get)
|
||||
worst_stream = max(avg_cpu, key=avg_cpu.get)
|
||||
|
||||
report.append(f" • Most efficient stream: {best_stream} ({avg_cpu[best_stream]:.1f}% avg CPU)")
|
||||
report.append(f" • Most resource-intensive: {worst_stream} ({avg_cpu[worst_stream]:.1f}% avg CPU)")
|
||||
|
||||
if avg_cpu[worst_stream] > 80:
|
||||
report.append(" ⚠️ High CPU usage detected - consider optimizing or scaling")
|
||||
elif avg_cpu[best_stream] < 20:
|
||||
report.append(" ✅ Excellent resource efficiency - system has headroom for more users")
|
||||
|
||||
report.append("")
|
||||
report.append("📈 SCALING INSIGHTS:")
|
||||
report.append("-" * 20)
|
||||
|
||||
total_tests = sum(len(df) for df in data_frames.values())
|
||||
report.append(f" • Total test duration: ~{total_tests} minutes across all streams")
|
||||
report.append(f" • System stability: {'✅ Excellent' if total_tests > 40 else '⚠️ Needs more testing'}")
|
||||
|
||||
# Save report
|
||||
with open('performance-logs/asteroid_performance_report.txt', 'w') as f:
|
||||
f.write('\n'.join(report))
|
||||
|
||||
print("📄 Report saved as: performance-logs/asteroid_performance_report.txt")
|
||||
return '\n'.join(report)
|
||||
|
||||
def main():
|
||||
print("🎵 Asteroid Radio Performance Analyzer")
|
||||
print("=" * 40)
|
||||
|
||||
# Load data
|
||||
data_frames = load_performance_data()
|
||||
|
||||
if not data_frames:
|
||||
print("❌ No performance data found!")
|
||||
return
|
||||
|
||||
# Create visualizations
|
||||
print("\n📊 Creating performance dashboard...")
|
||||
create_performance_dashboard(data_frames)
|
||||
|
||||
# Generate report
|
||||
print("\n📄 Generating performance report...")
|
||||
report = generate_performance_report(data_frames)
|
||||
|
||||
print("\n✅ Analysis complete!")
|
||||
print("\nGenerated files:")
|
||||
print(" 📊 performance-logs/asteroid_performance_dashboard.png")
|
||||
print(" 📄 performance-logs/asteroid_performance_report.txt")
|
||||
|
||||
print(f"\n🎯 Quick Summary:")
|
||||
print(f" Tests completed: {len(data_frames)}")
|
||||
total_records = sum(len(df) for df in data_frames.values())
|
||||
print(f" Data points collected: {total_records}")
|
||||
print(f" Stream formats tested: {', '.join(data_frames.keys())}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
:defsystem-depends-on (:radiance)
|
||||
:class "radiance:virtual-module"
|
||||
:depends-on (:slynk
|
||||
:lparallel
|
||||
:radiance
|
||||
:i-log4cl
|
||||
:r-clip
|
||||
|
|
@ -32,8 +33,13 @@
|
|||
:pathname "./"
|
||||
:components ((:file "app-utils")
|
||||
(:file "module")
|
||||
(:file "conditions")
|
||||
(:file "database")
|
||||
(:file "template-utils")
|
||||
(:file "stream-media")
|
||||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
(:file "stream-control")
|
||||
(:file "auth-routes")
|
||||
(:file "frontend-partials")
|
||||
(:file "asteroid")))
|
||||
|
|
|
|||
779
asteroid.lisp
779
asteroid.lisp
|
|
@ -19,53 +19,37 @@
|
|||
(merge-pathnames "music/library/"
|
||||
(asdf:system-source-directory :asteroid)))
|
||||
(defparameter *supported-formats* '("mp3" "flac" "ogg" "wav"))
|
||||
(defparameter *stream-base-url* "http://localhost:8000")
|
||||
|
||||
;; Configure JSON as the default API format
|
||||
(define-api-format json (data)
|
||||
"JSON API format for Radiance"
|
||||
(setf (header "Content-Type") "application/json")
|
||||
(cl-json:encode-json-to-string data))
|
||||
|
||||
;; ;; Authentication functions
|
||||
;; (defun require-authentication ()
|
||||
;; "Require user to be authenticated"
|
||||
;; (handler-case
|
||||
;; (unless (session:field "user-id")
|
||||
;; (radiance:redirect "/asteroid/login"))
|
||||
;; (error (e)
|
||||
;; (format t "Authentication error: ~a~%" e)
|
||||
;; (radiance:redirect "/asteroid/login"))))
|
||||
;; Set JSON as the default API format
|
||||
(setf *default-api-format* "json")
|
||||
|
||||
;; (defun require-role (role)
|
||||
;; "Require user to have a specific role"
|
||||
;; (handler-case
|
||||
;; (let ((current-user (get-current-user)))
|
||||
;; (unless (and current-user (user-has-role-p current-user role))
|
||||
;; (radiance:redirect "/asteroid/login")))
|
||||
;; (error (e)
|
||||
;; (format t "Role check error: ~a~%" e)
|
||||
;; (radiance:redirect "/asteroid/login"))))
|
||||
;; API Routes using Radiance's define-api
|
||||
;; API endpoints are accessed at /api/<name> automatically
|
||||
;; They use lambda-lists for parameters and api-output for responses
|
||||
|
||||
;; API Routes
|
||||
(define-page admin-scan-library #@"/admin/scan-library" ()
|
||||
(define-api asteroid/admin/scan-library () ()
|
||||
"API endpoint to scan music library"
|
||||
(require-role :admin)
|
||||
(handler-case
|
||||
(with-error-handling
|
||||
(let ((tracks-added (scan-music-library)))
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
(api-output `(("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)))))))
|
||||
("tracks-added" . ,tracks-added))))))
|
||||
|
||||
(define-page admin-tracks #@"/admin/tracks" ()
|
||||
(define-api asteroid/admin/tracks () ()
|
||||
"API endpoint to view all tracks in database"
|
||||
(require-authentication)
|
||||
(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")
|
||||
(with-error-handling
|
||||
(let ((tracks (with-db-error-handling "select"
|
||||
(db:select "tracks" (db:query :all)))))
|
||||
(api-output `(("status" . "success")
|
||||
("tracks" . ,(mapcar (lambda (track)
|
||||
`(("id" . ,(gethash "_id" track))
|
||||
("title" . ,(first (gethash "title" track)))
|
||||
|
|
@ -73,20 +57,194 @@
|
|||
("album" . ,(first (gethash "album" track)))
|
||||
("duration" . ,(first (gethash "duration" track)))
|
||||
("format" . ,(first (gethash "format" track)))
|
||||
("bitrate" . ,(first (gethash "bitrate" track)))
|
||||
("play-count" . ,(first (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)))))))
|
||||
("bitrate" . ,(first (gethash "bitrate" track)))))
|
||||
tracks)))))))
|
||||
|
||||
;; Playlist API endpoints
|
||||
(define-api asteroid/playlists () ()
|
||||
"Get all playlists for current user"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((user (get-current-user))
|
||||
(user-id-raw (gethash "_id" user))
|
||||
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw))
|
||||
(playlists (get-user-playlists user-id)))
|
||||
(api-output `(("status" . "success")
|
||||
("playlists" . ,(mapcar (lambda (playlist)
|
||||
(let ((name-val (gethash "name" playlist))
|
||||
(desc-val (gethash "description" playlist))
|
||||
(track-ids-val (gethash "track-ids" playlist))
|
||||
(created-val (gethash "created-date" playlist))
|
||||
(id-val (gethash "_id" playlist)))
|
||||
;; Calculate track count from comma-separated string
|
||||
;; Handle nil, empty string, or list containing empty string
|
||||
(let* ((track-ids-str (if (listp track-ids-val)
|
||||
(first track-ids-val)
|
||||
track-ids-val))
|
||||
(track-count (if (and track-ids-str
|
||||
(stringp track-ids-str)
|
||||
(not (string= track-ids-str "")))
|
||||
(length (cl-ppcre:split "," track-ids-str))
|
||||
0)))
|
||||
`(("id" . ,(if (listp id-val) (first id-val) id-val))
|
||||
("name" . ,(if (listp name-val) (first name-val) name-val))
|
||||
("description" . ,(if (listp desc-val) (first desc-val) desc-val))
|
||||
("track-count" . ,track-count)
|
||||
("created-date" . ,(if (listp created-val) (first created-val) created-val))))))
|
||||
playlists)))))))
|
||||
|
||||
(define-api asteroid/playlists/create (name &optional description) ()
|
||||
"Create a new playlist"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((user (get-current-user))
|
||||
(user-id-raw (gethash "_id" user))
|
||||
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw)))
|
||||
(create-playlist user-id name description)
|
||||
(if (string= "true" (post/get "browser"))
|
||||
(redirect "/asteroid/")
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playlist created successfully")))))))
|
||||
|
||||
(define-api asteroid/playlists/add-track (playlist-id track-id) ()
|
||||
"Add a track to a playlist"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let ((pl-id (parse-integer playlist-id :junk-allowed t))
|
||||
(tr-id (parse-integer track-id :junk-allowed t)))
|
||||
(add-track-to-playlist pl-id tr-id)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track added to playlist"))))))
|
||||
|
||||
(define-api asteroid/playlists/get (playlist-id) ()
|
||||
"Get playlist details with tracks"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((id (parse-integer playlist-id :junk-allowed t))
|
||||
(playlist (get-playlist-by-id id)))
|
||||
(if playlist
|
||||
(let* ((track-ids-raw (gethash "tracks" playlist))
|
||||
(track-ids (if (listp track-ids-raw) track-ids-raw (list track-ids-raw)))
|
||||
(tracks (mapcar (lambda (track-id)
|
||||
(let ((track-list (db:select "tracks" (db:query (:= "_id" track-id)))))
|
||||
(when (> (length track-list) 0)
|
||||
(first track-list))))
|
||||
track-ids))
|
||||
(valid-tracks (remove nil tracks)))
|
||||
(api-output `(("status" . "success")
|
||||
("playlist" . (("id" . ,id)
|
||||
("name" . ,(let ((n (gethash "name" playlist)))
|
||||
(if (listp n) (first n) n)))
|
||||
("tracks" . ,(mapcar (lambda (track)
|
||||
`(("id" . ,(gethash "_id" track))
|
||||
("title" . ,(gethash "title" track))
|
||||
("artist" . ,(gethash "artist" track))
|
||||
("album" . ,(gethash "album" track))))
|
||||
valid-tracks)))))))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Playlist not found"))
|
||||
:status 404)))))
|
||||
|
||||
;; API endpoint to get all tracks (for web player)
|
||||
(define-api asteroid/tracks () ()
|
||||
"Get all tracks for web player"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let ((tracks (with-db-error-handling "select"
|
||||
(db:select "tracks" (db:query :all)))))
|
||||
(api-output `(("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))
|
||||
("format" . ,(gethash "format" track))))
|
||||
tracks)))))))
|
||||
|
||||
;; Stream Control API Endpoints
|
||||
(define-api asteroid/stream/queue () ()
|
||||
"Get the current stream queue"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((queue (get-stream-queue)))
|
||||
(api-output `(("status" . "success")
|
||||
("queue" . ,(mapcar (lambda (track-id)
|
||||
(let ((track (get-track-by-id track-id)))
|
||||
`(("id" . ,track-id)
|
||||
("title" . ,(gethash "title" track))
|
||||
("artist" . ,(gethash "artist" track))
|
||||
("album" . ,(gethash "album" track)))))
|
||||
queue)))))))
|
||||
|
||||
(define-api asteroid/stream/queue/add (track-id &optional (position "end")) ()
|
||||
"Add a track to the stream queue"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((tr-id (parse-integer track-id :junk-allowed t))
|
||||
(pos (if (string= position "next") :next :end)))
|
||||
(add-to-stream-queue tr-id pos)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track added to stream queue"))))))
|
||||
|
||||
(define-api asteroid/stream/queue/remove (track-id) ()
|
||||
"Remove a track from the stream queue"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((tr-id (parse-integer track-id :junk-allowed t)))
|
||||
(remove-from-stream-queue tr-id)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track removed from stream queue"))))))
|
||||
|
||||
(define-api asteroid/stream/queue/clear () ()
|
||||
"Clear the entire stream queue"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(clear-stream-queue)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Stream queue cleared")))))
|
||||
|
||||
(define-api asteroid/stream/queue/add-playlist (playlist-id) ()
|
||||
"Add all tracks from a playlist to the stream queue"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((pl-id (parse-integer playlist-id :junk-allowed t)))
|
||||
(add-playlist-to-stream-queue pl-id)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playlist added to stream queue"))))))
|
||||
|
||||
(define-api asteroid/stream/queue/reorder (track-ids) ()
|
||||
"Reorder the stream queue (expects comma-separated track IDs)"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((ids (mapcar (lambda (id-str) (parse-integer id-str :junk-allowed t))
|
||||
(cl-ppcre:split "," track-ids))))
|
||||
(reorder-stream-queue ids)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Stream queue reordered"))))))
|
||||
|
||||
(define-api asteroid/stream/queue/load-m3u () ()
|
||||
"Load stream queue from stream-queue.m3u file"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((count (load-queue-from-m3u-file)))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Queue loaded from M3U file")
|
||||
("count" . ,count))))))
|
||||
|
||||
(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))))
|
||||
"Get a track by its ID - handles type mismatches"
|
||||
;; Try direct query first
|
||||
(let ((tracks (db:select "tracks" (db:query (:= "_id" track-id)))))
|
||||
(if (> (length tracks) 0)
|
||||
(first tracks)
|
||||
;; If not found, search manually (ID might be stored as list)
|
||||
(let ((all-tracks (db:select "tracks" (db:query :all))))
|
||||
(find-if (lambda (track)
|
||||
(let ((stored-id (gethash "_id" track)))
|
||||
(or (equal stored-id track-id)
|
||||
(and (listp stored-id) (equal (first stored-id) track-id)))))
|
||||
all-tracks)))))
|
||||
|
||||
(defun get-mime-type-for-format (format)
|
||||
"Get MIME type for audio format"
|
||||
|
|
@ -99,15 +257,19 @@
|
|||
|
||||
(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id))
|
||||
"Stream audio file by track ID"
|
||||
(handler-case
|
||||
(with-error-handling
|
||||
(let* ((id (parse-integer track-id))
|
||||
(track (get-track-by-id id)))
|
||||
(if track
|
||||
(unless track
|
||||
(signal-not-found "track" id))
|
||||
(let* ((file-path (first (gethash "file-path" track)))
|
||||
(format (first (gethash "format" track)))
|
||||
(file (probe-file file-path)))
|
||||
(if file
|
||||
(progn
|
||||
(unless file
|
||||
(error 'not-found-error
|
||||
:message "Audio file not found on disk"
|
||||
:resource-type "file"
|
||||
:resource-id file-path))
|
||||
;; Set appropriate headers for audio streaming
|
||||
(setf (radiance:header "Content-Type") (get-mime-type-for-format format))
|
||||
(setf (radiance:header "Accept-Ranges") "bytes")
|
||||
|
|
@ -116,22 +278,7 @@
|
|||
(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)))))))
|
||||
(alexandria:read-file-into-byte-vector file)))))
|
||||
|
||||
;; Player state management
|
||||
(defvar *current-track* nil "Currently playing track")
|
||||
|
|
@ -172,134 +319,480 @@
|
|||
(write-string (generate-css) out))))
|
||||
|
||||
;; Player control API endpoints
|
||||
(define-page api-play #@"/api/play" ()
|
||||
(define-api asteroid/player/play (track-id) ()
|
||||
"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))
|
||||
(with-error-handling
|
||||
(let* ((id (parse-integer track-id))
|
||||
(track (get-track-by-id id)))
|
||||
(if track
|
||||
(progn
|
||||
(unless track
|
||||
(signal-not-found "track" id))
|
||||
(setf *current-track* id)
|
||||
(setf *player-state* :playing)
|
||||
(setf *current-position* 0)
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
(api-output `(("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)))))))
|
||||
("player" . ,(get-player-status)))))))
|
||||
|
||||
(define-page api-pause #@"/api/pause" ()
|
||||
(define-api asteroid/player/pause () ()
|
||||
"Pause current playback"
|
||||
(setf *player-state* :paused)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playback paused")
|
||||
("player" . ,(get-player-status)))))
|
||||
|
||||
(define-page api-stop #@"/api/stop" ()
|
||||
(define-api asteroid/player/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")
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playback stopped")
|
||||
("player" . ,(get-player-status)))))
|
||||
|
||||
(define-page api-resume #@"/api/resume" ()
|
||||
(define-api asteroid/player/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")
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playback resumed")
|
||||
("player" . ,(get-player-status)))))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "error")
|
||||
("message" . "Player is not paused")))))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Player is not paused"))
|
||||
:status 400)))
|
||||
|
||||
(define-page api-player-status #@"/api/player-status" ()
|
||||
(define-api asteroid/player/status () ()
|
||||
"Get current player status"
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
(api-output `(("status" . "success")
|
||||
("player" . ,(get-player-status)))))
|
||||
|
||||
;; Front page
|
||||
;; Profile API Routes - TEMPORARILY COMMENTED OUT
|
||||
#|
|
||||
(define-page api-user-profile #@"/api/user/profile" ()
|
||||
"Get current user profile information"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(let ((current-user (auth:current-user)))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("user" . (("username" . ,(gethash "username" current-user))
|
||||
("role" . ,(gethash "role" current-user))
|
||||
("created_at" . ,(gethash "created_at" current-user))
|
||||
("last_active" . ,(get-universal-time))))))))
|
||||
|
||||
(define-page api-user-listening-stats #@"/api/user/listening-stats" ()
|
||||
"Get user listening statistics"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
;; TODO: Implement actual listening statistics from database
|
||||
;; For now, return mock data
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("stats" . (("total_listen_time" . 0)
|
||||
("tracks_played" . 0)
|
||||
("session_count" . 0)
|
||||
("favorite_genre" . "Unknown"))))))
|
||||
|
||||
(define-page api-user-recent-tracks #@"/api/user/recent-tracks" ()
|
||||
"Get user's recently played tracks"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
;; TODO: Implement actual recent tracks from database
|
||||
;; For now, return empty array
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("tracks" . #()))))
|
||||
|
||||
(define-page api-user-top-artists #@"/api/user/top-artists" ()
|
||||
"Get user's top artists"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
;; TODO: Implement actual top artists from database
|
||||
;; For now, return empty array
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("artists" . #()))))
|
||||
|
||||
(define-page api-user-export-data #@"/api/user/export-data" ()
|
||||
"Export user listening data"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(setf (radiance:header "Content-Disposition") "attachment; filename=listening-data.json")
|
||||
;; TODO: Implement actual data export
|
||||
(cl-json:encode-json-to-string
|
||||
`(("user" . ,(gethash "username" (auth:current-user)))
|
||||
("export_date" . ,(get-universal-time))
|
||||
("listening_history" . #())
|
||||
("statistics" . (("total_listen_time" . 0)
|
||||
("tracks_played" . 0)
|
||||
("session_count" . 0))))))
|
||||
|
||||
(define-page api-user-clear-history #@"/api/user/clear-history" ()
|
||||
"Clear user listening history"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
;; TODO: Implement actual history clearing
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("message" . "Listening history cleared successfully"))))
|
||||
|#
|
||||
|
||||
;; Front page - regular view by default
|
||||
(define-page front-page #@"/" ()
|
||||
"Main front page"
|
||||
(let ((template-path (merge-pathnames "template/front-page.chtml"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(clip:process-to-string
|
||||
(plump:parse (alexandria:read-file-into-string template-path))
|
||||
(load-template "front-page")
|
||||
:title "🎵 ASTEROID RADIO 🎵"
|
||||
:station-name "🎵 ASTEROID RADIO 🎵"
|
||||
:status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
|
||||
:listeners "0"
|
||||
:stream-quality "128kbps MP3"
|
||||
:stream-base-url *stream-base-url*
|
||||
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
|
||||
:default-stream-encoding "audio/aac"
|
||||
:default-stream-encoding-desc "AAC 96kbps Stereo"
|
||||
:now-playing-artist "The Void"
|
||||
:now-playing-track "Silence"
|
||||
:now-playing-album "Startup Sounds"
|
||||
:now-playing-duration "∞")))
|
||||
:now-playing-duration "∞"))
|
||||
|
||||
;; Frameset wrapper for persistent player mode
|
||||
(define-page frameset-wrapper #@"/frameset" ()
|
||||
"Frameset wrapper with persistent audio player"
|
||||
(clip:process-to-string
|
||||
(load-template "frameset-wrapper")
|
||||
:title "🎵 ASTEROID RADIO 🎵"))
|
||||
|
||||
;; Content frame - front page content without player
|
||||
(define-page front-page-content #@"/content" ()
|
||||
"Front page content (displayed in content frame)"
|
||||
(clip:process-to-string
|
||||
(load-template "front-page-content")
|
||||
:title "🎵 ASTEROID RADIO 🎵"
|
||||
:station-name "🎵 ASTEROID RADIO 🎵"
|
||||
:status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
|
||||
:listeners "0"
|
||||
:stream-quality "128kbps MP3"
|
||||
:stream-base-url *stream-base-url*
|
||||
:now-playing-artist "The Void"
|
||||
:now-playing-track "Silence"
|
||||
:now-playing-album "Startup Sounds"
|
||||
:now-playing-duration "∞"))
|
||||
|
||||
;; Persistent audio player frame (bottom frame)
|
||||
(define-page audio-player-frame #@"/audio-player-frame" ()
|
||||
"Persistent audio player frame (bottom of page)"
|
||||
(clip:process-to-string
|
||||
(load-template "audio-player-frame")
|
||||
:stream-base-url *stream-base-url*
|
||||
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
|
||||
:default-stream-encoding "audio/aac"))
|
||||
|
||||
;; Configure static file serving for other files
|
||||
(define-page static #@"/static/(.*)" (:uri-groups (path))
|
||||
(serve-file (merge-pathnames (concatenate 'string "static/" path)
|
||||
(serve-file (merge-pathnames (format nil "static/~a" path)
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
|
||||
;; Status check functions
|
||||
(defun check-icecast-status ()
|
||||
"Check if Icecast server is running and accessible"
|
||||
(handler-case
|
||||
(let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
|
||||
:want-stream nil
|
||||
:connection-timeout 2)))
|
||||
(if response "🟢 Running" "🔴 Not Running"))
|
||||
(error () "🔴 Not Running")))
|
||||
|
||||
(defun check-liquidsoap-status ()
|
||||
"Check if Liquidsoap is running via Docker"
|
||||
(handler-case
|
||||
(let* ((output (with-output-to-string (stream)
|
||||
(uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}")
|
||||
:output stream
|
||||
:error-output nil
|
||||
:ignore-error-status t)))
|
||||
(running-p (search "Up" output)))
|
||||
(if running-p "🟢 Running" "🔴 Not Running"))
|
||||
(error () "🔴 Not Running")))
|
||||
|
||||
;; Admin page (requires authentication)
|
||||
(define-page admin #@"/admin" ()
|
||||
"Admin dashboard"
|
||||
(require-authentication)
|
||||
(let ((template-path (merge-pathnames "template/admin.chtml"
|
||||
(asdf:system-source-directory :asteroid)))
|
||||
(track-count (handler-case
|
||||
(let ((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))
|
||||
(load-template "admin")
|
||||
:title "🎵 ASTEROID RADIO - Admin Dashboard"
|
||||
:server-status "🟢 Running"
|
||||
:database-status (handler-case
|
||||
(if (db:connected-p) "🟢 Connected" "🔴 Disconnected")
|
||||
(error () "🔴 No Database Backend"))
|
||||
:liquidsoap-status "🔴 Not Running"
|
||||
:icecast-status "🔴 Not Running"
|
||||
:liquidsoap-status (check-liquidsoap-status)
|
||||
:icecast-status (check-icecast-status)
|
||||
:track-count (format nil "~d" track-count)
|
||||
:library-path "/home/glenn/Projects/Code/asteroid/music/library/")))
|
||||
:library-path "/home/glenn/Projects/Code/asteroid/music/library/"
|
||||
:stream-base-url *stream-base-url*
|
||||
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*))))
|
||||
|
||||
(define-page player #@"/player" ()
|
||||
(let ((template-path (merge-pathnames "template/player.chtml"
|
||||
;; User Management page (requires authentication)
|
||||
(define-page users-management #@"/admin/user" ()
|
||||
"User Management dashboard"
|
||||
(require-authentication)
|
||||
(clip:process-to-string
|
||||
(load-template "users")
|
||||
:title "🎵 ASTEROID RADIO - User Management"))
|
||||
|
||||
;; User Profile page (requires authentication)
|
||||
(define-page user-profile #@"/profile" ()
|
||||
"User profile page"
|
||||
(require-authentication)
|
||||
(clip:process-to-string
|
||||
(load-template "profile")
|
||||
:title "🎧 admin - Profile | Asteroid Radio"
|
||||
:username "admin"
|
||||
:user-role "admin"
|
||||
:join-date "Unknown"
|
||||
:last-active "Unknown"
|
||||
:total-listen-time "0h 0m"
|
||||
:tracks-played "0"
|
||||
:session-count "0"
|
||||
:favorite-genre "Unknown"
|
||||
:recent-track-1-title ""
|
||||
:recent-track-1-artist ""
|
||||
:recent-track-1-duration ""
|
||||
:recent-track-1-played-at ""
|
||||
:recent-track-2-title ""
|
||||
:recent-track-2-artist ""
|
||||
:recent-track-2-duration ""
|
||||
:recent-track-2-played-at ""
|
||||
:recent-track-3-title ""
|
||||
:recent-track-3-artist ""
|
||||
:recent-track-3-duration ""
|
||||
:recent-track-3-played-at ""
|
||||
:top-artist-1 ""
|
||||
:top-artist-1-plays ""
|
||||
:top-artist-2 ""
|
||||
:top-artist-2-plays ""
|
||||
:top-artist-3 ""
|
||||
:top-artist-3-plays ""
|
||||
:top-artist-4 ""
|
||||
:top-artist-4-plays ""
|
||||
:top-artist-5 ""
|
||||
:top-artist-5-plays ""))
|
||||
|
||||
;; Helper functions for profile page - TEMPORARILY COMMENTED OUT
|
||||
#|
|
||||
(defun format-timestamp (stream timestamp &key format)
|
||||
"Format a timestamp for display"
|
||||
(declare (ignore stream format))
|
||||
(if timestamp
|
||||
(multiple-value-bind (second minute hour date month year)
|
||||
(decode-universal-time timestamp)
|
||||
(format nil "~a ~d, ~d"
|
||||
(nth (1- month) '("January" "February" "March" "April" "May" "June"
|
||||
"July" "August" "September" "October" "November" "December"))
|
||||
date year))
|
||||
"Unknown"))
|
||||
|
||||
(defun format-relative-time (timestamp)
|
||||
"Format a timestamp as relative time (e.g., '2 hours ago')"
|
||||
(if timestamp
|
||||
(let* ((now (get-universal-time))
|
||||
(diff (- now timestamp))
|
||||
(minutes (floor diff 60))
|
||||
(hours (floor minutes 60))
|
||||
(days (floor hours 24)))
|
||||
(cond
|
||||
((< diff 60) "Just now")
|
||||
((< minutes 60) (format nil "~d minute~p ago" minutes minutes))
|
||||
((< hours 24) (format nil "~d hour~p ago" hours hours))
|
||||
(t (format nil "~d day~p ago" days days))))
|
||||
"Unknown"))
|
||||
|
||||
;; User Profile page (requires authentication)
|
||||
(define-page user-profile #@"/profile" ()
|
||||
"User profile page with listening statistics and track data"
|
||||
(require-authentication)
|
||||
(let* ((current-user (auth:current-user))
|
||||
(username (gethash "username" current-user))
|
||||
(template-path (merge-pathnames "template/profile.ctml"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(clip:process-to-string
|
||||
(plump:parse (alexandria:read-file-into-string template-path))
|
||||
:title (format nil "🎧 ~a - Profile | Asteroid Radio" username)
|
||||
:username (or username "Unknown User")
|
||||
:user-role "listener"
|
||||
:join-date "Unknown"
|
||||
:last-active "Unknown"
|
||||
:total-listen-time "0h 0m"
|
||||
:tracks-played "0"
|
||||
:session-count "0"
|
||||
:favorite-genre "Unknown"
|
||||
:recent-track-1-title ""
|
||||
:recent-track-1-artist ""
|
||||
:recent-track-1-duration ""
|
||||
:recent-track-1-played-at ""
|
||||
:recent-track-2-title ""
|
||||
:recent-track-2-artist ""
|
||||
:recent-track-2-duration ""
|
||||
:recent-track-2-played-at ""
|
||||
:recent-track-3-title ""
|
||||
:recent-track-3-artist ""
|
||||
:recent-track-3-duration ""
|
||||
:recent-track-3-played-at ""
|
||||
:top-artist-1 ""
|
||||
:top-artist-1-plays ""
|
||||
:top-artist-2 ""
|
||||
:top-artist-2-plays ""
|
||||
:top-artist-3 ""
|
||||
:top-artist-3-plays ""
|
||||
:top-artist-4 ""
|
||||
:top-artist-4-plays ""
|
||||
:top-artist-5 ""
|
||||
:top-artist-5-plays "")))
|
||||
|#
|
||||
|
||||
;; Auth status API endpoint
|
||||
(define-api asteroid/auth-status () ()
|
||||
"Check if user is logged in and their role"
|
||||
(with-error-handling
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(user (when user-id (find-user-by-id user-id))))
|
||||
(api-output `(("loggedIn" . ,(if user t nil))
|
||||
("isAdmin" . ,(if (and user (user-has-role-p user :admin)) t nil))
|
||||
("username" . ,(if user
|
||||
(let ((username (gethash "username" user)))
|
||||
(if (listp username) (first username) username))
|
||||
nil)))))))
|
||||
|
||||
;; User profile API endpoints
|
||||
(define-api asteroid/user/profile () ()
|
||||
"Get current user profile information"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(user (find-user-by-id user-id)))
|
||||
(if user
|
||||
(api-output `(("status" . "success")
|
||||
("user" . (("username" . ,(first (gethash "username" user)))
|
||||
("email" . ,(first (gethash "email" user)))
|
||||
("role" . ,(first (gethash "role" user)))
|
||||
("created_at" . ,(first (gethash "created-date" user)))
|
||||
("last_active" . ,(first (gethash "last-login" user)))))))
|
||||
(signal-not-found "user" user-id)))))
|
||||
|
||||
(define-api asteroid/user/listening-stats () ()
|
||||
"Get user listening statistics"
|
||||
(require-authentication)
|
||||
(api-output `(("status" . "success")
|
||||
("stats" . (("total_listen_time" . 0)
|
||||
("tracks_played" . 0)
|
||||
("session_count" . 0)
|
||||
("favorite_genre" . "Unknown"))))))
|
||||
|
||||
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
|
||||
"Get recently played tracks for user"
|
||||
(require-authentication)
|
||||
(api-output `(("status" . "success")
|
||||
("tracks" . ()))))
|
||||
|
||||
(define-api asteroid/user/top-artists (&optional (limit "5")) ()
|
||||
"Get top artists for user"
|
||||
(require-authentication)
|
||||
(api-output `(("status" . "success")
|
||||
("artists" . ()))))
|
||||
|
||||
;; Register page (GET)
|
||||
(define-page register #@"/register" ()
|
||||
"User registration page"
|
||||
(let ((username (radiance:post-var "username"))
|
||||
(email (radiance:post-var "email"))
|
||||
(password (radiance:post-var "password"))
|
||||
(confirm-password (radiance:post-var "confirm-password")))
|
||||
(if (and username password)
|
||||
;; Handle registration form submission
|
||||
(cond
|
||||
;; Validate passwords match
|
||||
((not (string= password confirm-password))
|
||||
(render-template-with-plist "register"
|
||||
:title "Asteroid Radio - Register"
|
||||
:display-error "display: block;"
|
||||
:display-success "display: none;"
|
||||
:error-message "Passwords do not match"
|
||||
:success-message ""))
|
||||
|
||||
;; Check if username already exists
|
||||
((find-user-by-username username)
|
||||
(render-template-with-plist "register"
|
||||
:title "Asteroid Radio - Register"
|
||||
:display-error "display: block;"
|
||||
:display-success "display: none;"
|
||||
:error-message "Username already exists"
|
||||
:success-message ""))
|
||||
|
||||
;; Create the user
|
||||
(t
|
||||
(if (create-user username email password :role :listener :active t)
|
||||
(progn
|
||||
;; Auto-login after successful registration
|
||||
(let ((user (find-user-by-username username)))
|
||||
(when user
|
||||
(let ((user-id (gethash "_id" user)))
|
||||
(setf (session:field "user-id") (if (listp user-id) (first user-id) user-id)))))
|
||||
;; Redirect new users to their profile page
|
||||
(radiance:redirect "/asteroid/profile"))
|
||||
(render-template-with-plist "register"
|
||||
:title "Asteroid Radio - Register"
|
||||
:display-error "display: block;"
|
||||
:display-success "display: none;"
|
||||
:error-message "Registration failed. Please try again."
|
||||
:success-message ""))))
|
||||
;; Show registration form (no POST data)
|
||||
(render-template-with-plist "register"
|
||||
:title "Asteroid Radio - Register"
|
||||
:display-error "display: none;"
|
||||
:display-success "display: none;"
|
||||
:error-message ""
|
||||
:success-message ""))))
|
||||
|
||||
(define-page player #@"/player" ()
|
||||
(clip:process-to-string
|
||||
(load-template "player")
|
||||
:title "Asteroid Radio - Web Player"
|
||||
:stream-url "http://localhost:8000/asteroid"
|
||||
:stream-base-url *stream-base-url*
|
||||
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
|
||||
:bitrate "128kbps MP3"
|
||||
:now-playing-artist "The Void"
|
||||
:now-playing-track "Silence"
|
||||
:now-playing-album "Startup Sounds"
|
||||
:player-status "Stopped")))
|
||||
:player-status "Stopped"))
|
||||
|
||||
(define-page status-api #@"/status" ()
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "running")
|
||||
;; Player content frame (for frameset mode)
|
||||
(define-page player-content #@"/player-content" ()
|
||||
"Player page content (displayed in content frame)"
|
||||
(clip:process-to-string
|
||||
(load-template "player-content")
|
||||
:title "Asteroid Radio - Web Player"
|
||||
:stream-base-url *stream-base-url*
|
||||
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
|
||||
:default-stream-encoding "audio/aac"))
|
||||
|
||||
(define-page popout-player #@"/popout-player" ()
|
||||
"Pop-out player window"
|
||||
(clip:process-to-string
|
||||
(load-template "popout-player")
|
||||
:stream-base-url *stream-base-url*
|
||||
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
|
||||
:default-stream-encoding "audio/aac"))
|
||||
|
||||
(define-api asteroid/status () ()
|
||||
"Get server status"
|
||||
(api-output `(("status" . "running")
|
||||
("server" . "asteroid-radio")
|
||||
("version" . "0.1.0")
|
||||
("uptime" . ,(get-universal-time))
|
||||
|
|
@ -307,15 +800,14 @@
|
|||
("artist" . "The Void")
|
||||
("album" . "Startup Sounds")))
|
||||
("listeners" . 0)
|
||||
("stream-url" . "http://localhost:8000/asteroid.mp3")
|
||||
("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
("stream-status" . "live"))))
|
||||
|
||||
;; Live stream status from Icecast
|
||||
(define-page icecast-status #@"/api/icecast-status" ()
|
||||
(define-api asteroid/icecast-status () ()
|
||||
"Get live status from Icecast server"
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(handler-case
|
||||
(let* ((icecast-url "http://localhost:8000/admin/stats.xml")
|
||||
(with-error-handling
|
||||
(let* ((icecast-url (format nil "~a/admin/stats.xml" *stream-base-url*))
|
||||
(response (drakma:http-request icecast-url
|
||||
:want-stream nil
|
||||
:basic-authorization '("admin" "asteroid_admin_2024"))))
|
||||
|
|
@ -331,21 +823,21 @@
|
|||
(let* ((source-section (subseq xml-string match-start
|
||||
(or (cl-ppcre:scan "</source>" xml-string :start match-start)
|
||||
(length xml-string))))
|
||||
(title (or (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown"))
|
||||
(listeners (or (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" source-section "\\1") "0")))
|
||||
(titlep (cl-ppcre:all-matches "<title>" source-section))
|
||||
(listenersp (cl-ppcre:all-matches "<listeners>" source-section))
|
||||
(title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown"))
|
||||
(listeners (if listenersp (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" source-section "\\1") "0")))
|
||||
;; Return JSON in format expected by frontend
|
||||
(cl-json:encode-json-to-string
|
||||
`(("icestats" . (("source" . (("listenurl" . "http://localhost:8000/asteroid.mp3")
|
||||
(api-output
|
||||
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
("title" . ,title)
|
||||
("listeners" . ,(parse-integer listeners :junk-allowed t)))))))))
|
||||
;; No source found, return empty
|
||||
(cl-json:encode-json-to-string
|
||||
(api-output
|
||||
`(("icestats" . (("source" . nil))))))))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("error" . "Could not connect to Icecast server")))))
|
||||
(error (e)
|
||||
(cl-json:encode-json-to-string
|
||||
`(("error" . ,(format nil "Icecast connection failed: ~a" e)))))))
|
||||
(api-output
|
||||
`(("error" . "Could not connect to Icecast server"))
|
||||
:status 503)))))
|
||||
|
||||
|
||||
;; RADIANCE server management functions
|
||||
|
|
@ -392,8 +884,12 @@
|
|||
|
||||
(defun -main (&optional args (debug t))
|
||||
(declare (ignorable args))
|
||||
(when (uiop:getenvp "ASTEROID_STREAM_URL")
|
||||
(setf *stream-base-url* (uiop:getenv "ASTEROID_STREAM_URL")))
|
||||
(format t "~&args of asteroid: ~A~%" args)
|
||||
(format t "~%🎵 ASTEROID RADIO - Music for Hackers 🎵~%")
|
||||
(format t "Using stream server at ~a~%" *stream-base-url*)
|
||||
|
||||
(format t "Starting RADIANCE web server...~%")
|
||||
(when debug
|
||||
(slynk:create-server :port 4009 :dont-close t))
|
||||
|
|
@ -404,5 +900,8 @@
|
|||
;; Initialize user management before server starts
|
||||
(initialize-user-system)
|
||||
|
||||
;; TODO: Add auto-scan on startup once database timing issues are resolved
|
||||
;; For now, use the "Scan Library" button in the admin interface
|
||||
|
||||
(run-server))
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@
|
|||
(define-page login #@"/login" ()
|
||||
"User login page"
|
||||
(let ((username (radiance:post-var "username"))
|
||||
(password (radiance:post-var "password"))
|
||||
(template-path (merge-pathnames "template/login.chtml"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(password (radiance:post-var "password")))
|
||||
(if (and username password)
|
||||
;; Handle login form submission
|
||||
(let ((user (authenticate-user username password)))
|
||||
|
|
@ -19,22 +17,28 @@
|
|||
(format t "Login successful for user: ~a~%" (gethash "username" user))
|
||||
(handler-case
|
||||
(progn
|
||||
(let ((user-id (gethash "_id" user)))
|
||||
(let* ((user-id (gethash "_id" user))
|
||||
(user-role-raw (gethash "role" user))
|
||||
(user-role (if (listp user-role-raw) (first user-role-raw) user-role-raw))
|
||||
(redirect-path (cond
|
||||
;; Admin users go to admin dashboard
|
||||
((string-equal user-role "admin") "/asteroid/admin")
|
||||
;; All other users go to their profile
|
||||
(t "/asteroid/profile"))))
|
||||
(format t "User ID from DB: ~a~%" user-id)
|
||||
(setf (session:field "user-id") (if (listp user-id) (first user-id) user-id)))
|
||||
(radiance:redirect "/asteroid/admin"))
|
||||
(format t "User role: ~a, redirecting to: ~a~%" user-role redirect-path)
|
||||
(setf (session:field "user-id") (if (listp user-id) (first user-id) user-id))
|
||||
(radiance:redirect redirect-path)))
|
||||
(error (e)
|
||||
(format t "Session error: ~a~%" e)
|
||||
"Login successful but session error occurred")))
|
||||
;; Login failed - show form with error
|
||||
(clip:process-to-string
|
||||
(plump:parse (alexandria:read-file-into-string template-path))
|
||||
(render-template-with-plist "login"
|
||||
:title "Asteroid Radio - Login"
|
||||
:error-message "Invalid username or password"
|
||||
:display-error "display: block;")))
|
||||
;; Show login form (no POST data)
|
||||
(clip:process-to-string
|
||||
(plump:parse (alexandria:read-file-into-string template-path))
|
||||
(render-template-with-plist "login"
|
||||
:title "Asteroid Radio - Login"
|
||||
:error-message ""
|
||||
:display-error "display: none;"))))
|
||||
|
|
@ -46,14 +50,12 @@
|
|||
(radiance:redirect "/asteroid/"))
|
||||
|
||||
;; API: Get all users (admin only)
|
||||
(define-page api-users #@"/api/users" ()
|
||||
(define-api asteroid/users () ()
|
||||
"API endpoint to get all users"
|
||||
(require-role :admin)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(handler-case
|
||||
(let ((users (get-all-users)))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
(api-output `(("status" . "success")
|
||||
("users" . ,(mapcar (lambda (user)
|
||||
`(("id" . ,(if (listp (gethash "_id" user))
|
||||
(first (gethash "_id" user))
|
||||
|
|
@ -66,21 +68,40 @@
|
|||
("last-login" . ,(first (gethash "last-login" user)))))
|
||||
users)))))
|
||||
(error (e)
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "error")
|
||||
("message" . ,(format nil "Error retrieving users: ~a" e)))))))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error retrieving users: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
;; API: Get user statistics (admin only)
|
||||
(define-page api-user-stats #@"/api/user-stats" ()
|
||||
(define-api asteroid/user-stats () ()
|
||||
"API endpoint to get user statistics"
|
||||
(require-role :admin)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(handler-case
|
||||
(let ((stats (get-user-stats)))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
(api-output `(("status" . "success")
|
||||
("stats" . ,stats))))
|
||||
(error (e)
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "error")
|
||||
("message" . ,(format nil "Error retrieving user stats: ~a" e)))))))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error retrieving user stats: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
;; API: Create new user (admin only)
|
||||
(define-api asteroid/users/create (username email password role) ()
|
||||
"API endpoint to create a new user"
|
||||
(require-role :admin)
|
||||
(handler-case
|
||||
(if (and username email password)
|
||||
(let ((role-keyword (intern (string-upcase role) :keyword)))
|
||||
(if (create-user username email password :role role-keyword :active t)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . ,(format nil "User ~a created successfully" username))))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Failed to create user"))
|
||||
:status 500)))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Missing required fields"))
|
||||
:status 400))
|
||||
(error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error creating user: ~a" e)))
|
||||
:status 500))))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Comprehensive Asteroid Performance Testing Script
|
||||
# Tests Docker streaming + Asteroid web app together
|
||||
# Usage: ./comprehensive-performance-test.sh [aac|mp3-high|mp3-low]
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCKER_DIR="$SCRIPT_DIR/docker"
|
||||
TEST_DURATION=900 # 15 minutes in seconds
|
||||
STREAM_TYPE="${1:-aac}" # Default to AAC if not specified
|
||||
|
||||
# Log file names based on stream type
|
||||
case "$STREAM_TYPE" in
|
||||
"aac")
|
||||
LOG_PREFIX="test-aac"
|
||||
STREAM_DESC="AAC 96kbps"
|
||||
;;
|
||||
"mp3-high")
|
||||
LOG_PREFIX="test-mp3-high"
|
||||
STREAM_DESC="MP3 128kbps"
|
||||
;;
|
||||
"mp3-low")
|
||||
LOG_PREFIX="test-mp3-low"
|
||||
STREAM_DESC="MP3 64kbps"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [aac|mp3-high|mp3-low]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Create logs directory
|
||||
LOGS_DIR="$SCRIPT_DIR/performance-logs"
|
||||
mkdir -p "$LOGS_DIR"
|
||||
|
||||
# Timestamp for this test run
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
LOG_FILE="$LOGS_DIR/${LOG_PREFIX}_${TIMESTAMP}.log"
|
||||
|
||||
echo "=== Comprehensive Asteroid Performance Test ===" | tee "$LOG_FILE"
|
||||
echo "Stream Type: $STREAM_DESC" | tee -a "$LOG_FILE"
|
||||
echo "Test Duration: 15 minutes" | tee -a "$LOG_FILE"
|
||||
echo "Started at: $(date)" | tee -a "$LOG_FILE"
|
||||
echo "Log file: $LOG_FILE" | tee -a "$LOG_FILE"
|
||||
echo "=========================================" | tee -a "$LOG_FILE"
|
||||
|
||||
# Function to cleanup on exit
|
||||
cleanup() {
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "=== CLEANUP STARTED ===" | tee -a "$LOG_FILE"
|
||||
|
||||
# Stop Asteroid application
|
||||
if [ ! -z "$ASTEROID_PID" ] && kill -0 "$ASTEROID_PID" 2>/dev/null; then
|
||||
echo "Stopping Asteroid application (PID: $ASTEROID_PID)..." | tee -a "$LOG_FILE"
|
||||
kill "$ASTEROID_PID" 2>/dev/null || true
|
||||
sleep 2
|
||||
kill -9 "$ASTEROID_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Stop Docker containers
|
||||
echo "Stopping Docker containers..." | tee -a "$LOG_FILE"
|
||||
cd "$DOCKER_DIR"
|
||||
docker compose down 2>/dev/null || true
|
||||
|
||||
# Stop monitoring
|
||||
if [ ! -z "$MONITOR_PID" ] && kill -0 "$MONITOR_PID" 2>/dev/null; then
|
||||
echo "Stopping monitoring..." | tee -a "$LOG_FILE"
|
||||
kill "$MONITOR_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Cleanup completed at: $(date)" | tee -a "$LOG_FILE"
|
||||
echo "=== TEST FINISHED ===" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Set trap for cleanup
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Step 1: Start Docker containers
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "=== STARTING DOCKER CONTAINERS ===" | tee -a "$LOG_FILE"
|
||||
cd "$DOCKER_DIR"
|
||||
|
||||
# Stop any existing containers
|
||||
docker compose down 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Start containers
|
||||
echo "Starting Icecast2 and Liquidsoap containers..." | tee -a "$LOG_FILE"
|
||||
docker compose up -d 2>&1 | tee -a "$LOG_FILE"
|
||||
|
||||
# Wait for containers to be ready
|
||||
echo "Waiting for containers to initialize..." | tee -a "$LOG_FILE"
|
||||
sleep 10
|
||||
|
||||
# Verify containers are running
|
||||
if ! docker compose ps | grep -q "Up"; then
|
||||
echo "ERROR: Docker containers failed to start!" | tee -a "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Docker containers started successfully" | tee -a "$LOG_FILE"
|
||||
|
||||
# Step 2: Start Asteroid application
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "=== STARTING ASTEROID APPLICATION ===" | tee -a "$LOG_FILE"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Build if needed
|
||||
if [ ! -f "./asteroid" ]; then
|
||||
echo "Building Asteroid executable..." | tee -a "$LOG_FILE"
|
||||
make 2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
# Start Asteroid in background
|
||||
echo "Starting Asteroid web application..." | tee -a "$LOG_FILE"
|
||||
./asteroid > "$LOGS_DIR/${LOG_PREFIX}_asteroid_${TIMESTAMP}.log" 2>&1 &
|
||||
ASTEROID_PID=$!
|
||||
|
||||
# Wait for Asteroid to start
|
||||
echo "Waiting for Asteroid to initialize..." | tee -a "$LOG_FILE"
|
||||
sleep 5
|
||||
|
||||
# Verify Asteroid is running
|
||||
if ! kill -0 "$ASTEROID_PID" 2>/dev/null; then
|
||||
echo "ERROR: Asteroid application failed to start!" | tee -a "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Asteroid application started successfully (PID: $ASTEROID_PID)" | tee -a "$LOG_FILE"
|
||||
|
||||
# Step 3: Wait for full system initialization
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "=== SYSTEM INITIALIZATION ===" | tee -a "$LOG_FILE"
|
||||
echo "Waiting for full system initialization..." | tee -a "$LOG_FILE"
|
||||
sleep 10
|
||||
|
||||
# Test connectivity
|
||||
echo "Testing system connectivity..." | tee -a "$LOG_FILE"
|
||||
|
||||
# Test Icecast
|
||||
if curl -s "http://localhost:8000/" > /dev/null; then
|
||||
echo "✓ Icecast2 responding on port 8000" | tee -a "$LOG_FILE"
|
||||
else
|
||||
echo "⚠ Icecast2 not responding" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
# Test Asteroid web interface
|
||||
if curl -s "http://localhost:8080/asteroid/" > /dev/null; then
|
||||
echo "✓ Asteroid web interface responding on port 8080" | tee -a "$LOG_FILE"
|
||||
else
|
||||
echo "⚠ Asteroid web interface not responding" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
# Step 4: Start monitoring
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "=== STARTING PERFORMANCE MONITORING ===" | tee -a "$LOG_FILE"
|
||||
echo "Stream: $STREAM_DESC" | tee -a "$LOG_FILE"
|
||||
echo "Duration: 15 minutes" | tee -a "$LOG_FILE"
|
||||
echo "Monitoring started at: $(date)" | tee -a "$LOG_FILE"
|
||||
|
||||
# Create monitoring function
|
||||
monitor_performance() {
|
||||
local monitor_log="$LOGS_DIR/${LOG_PREFIX}_monitor_${TIMESTAMP}.log"
|
||||
local csv_log="$LOGS_DIR/${LOG_PREFIX}_data_${TIMESTAMP}.csv"
|
||||
|
||||
# CSV header
|
||||
echo "timestamp,icecast_cpu,icecast_mem_mb,liquidsoap_cpu,liquidsoap_mem_mb,asteroid_cpu,asteroid_mem_mb,system_mem_used_gb,system_mem_total_gb" > "$csv_log"
|
||||
|
||||
local start_time=$(date +%s)
|
||||
local end_time=$((start_time + TEST_DURATION))
|
||||
|
||||
while [ $(date +%s) -lt $end_time ]; do
|
||||
local current_time=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Get Docker container stats
|
||||
local icecast_stats=$(docker stats asteroid-icecast --no-stream --format "{{.CPUPerc}},{{.MemUsage}}" 2>/dev/null || echo "0.00%,0B / 0B")
|
||||
local liquidsoap_stats=$(docker stats asteroid-liquidsoap --no-stream --format "{{.CPUPerc}},{{.MemUsage}}" 2>/dev/null || echo "0.00%,0B / 0B")
|
||||
|
||||
# Parse Docker stats
|
||||
local icecast_cpu=$(echo "$icecast_stats" | cut -d',' -f1 | sed 's/%//')
|
||||
local icecast_mem_raw=$(echo "$icecast_stats" | cut -d',' -f2 | cut -d'/' -f1 | sed 's/[^0-9.]//g')
|
||||
local icecast_mem_mb=$(echo "$icecast_mem_raw" | awk '{print $1/1024/1024}')
|
||||
|
||||
local liquidsoap_cpu=$(echo "$liquidsoap_stats" | cut -d',' -f1 | sed 's/%//')
|
||||
local liquidsoap_mem_raw=$(echo "$liquidsoap_stats" | cut -d',' -f2 | cut -d'/' -f1 | sed 's/[^0-9.]//g')
|
||||
local liquidsoap_mem_mb=$(echo "$liquidsoap_mem_raw" | awk '{print $1/1024/1024}')
|
||||
|
||||
# Get Asteroid process stats
|
||||
local asteroid_cpu="0.0"
|
||||
local asteroid_mem_mb="0.0"
|
||||
if kill -0 "$ASTEROID_PID" 2>/dev/null; then
|
||||
local asteroid_stats=$(ps -p "$ASTEROID_PID" -o %cpu,rss --no-headers 2>/dev/null || echo "0.0 0")
|
||||
asteroid_cpu=$(echo "$asteroid_stats" | awk '{print $1}')
|
||||
local asteroid_mem_kb=$(echo "$asteroid_stats" | awk '{print $2}')
|
||||
asteroid_mem_mb=$(echo "$asteroid_mem_kb" | awk '{print $1/1024}')
|
||||
fi
|
||||
|
||||
# Get system memory
|
||||
local mem_info=$(free -g | grep "^Mem:")
|
||||
local system_mem_used=$(echo "$mem_info" | awk '{print $3}')
|
||||
local system_mem_total=$(echo "$mem_info" | awk '{print $2}')
|
||||
|
||||
# Log to console and file
|
||||
printf "[%s] Icecast: %s%% CPU, %.1fMB | Liquidsoap: %s%% CPU, %.1fMB | Asteroid: %s%% CPU, %.1fMB | System: %sGB/%sGB\n" \
|
||||
"$current_time" "$icecast_cpu" "$icecast_mem_mb" "$liquidsoap_cpu" "$liquidsoap_mem_mb" \
|
||||
"$asteroid_cpu" "$asteroid_mem_mb" "$system_mem_used" "$system_mem_total" | tee -a "$LOG_FILE"
|
||||
|
||||
# Log to CSV
|
||||
printf "%s,%.2f,%.1f,%.2f,%.1f,%.2f,%.1f,%s,%s\n" \
|
||||
"$current_time" "$icecast_cpu" "$icecast_mem_mb" "$liquidsoap_cpu" "$liquidsoap_mem_mb" \
|
||||
"$asteroid_cpu" "$asteroid_mem_mb" "$system_mem_used" "$system_mem_total" >> "$csv_log"
|
||||
|
||||
sleep 5 # Sample every 5 seconds
|
||||
done
|
||||
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "Monitoring completed at: $(date)" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Start monitoring in background
|
||||
monitor_performance &
|
||||
MONITOR_PID=$!
|
||||
|
||||
# Step 5: Generate some web traffic during monitoring
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "=== GENERATING WEB TRAFFIC ===" | tee -a "$LOG_FILE"
|
||||
|
||||
# Function to generate light web traffic
|
||||
generate_traffic() {
|
||||
sleep 60 # Wait 1 minute before starting traffic
|
||||
|
||||
for i in {1..10}; do
|
||||
# Test main pages
|
||||
curl -s "http://localhost:8080/asteroid/" > /dev/null 2>&1 || true
|
||||
sleep 30
|
||||
|
||||
# Test API endpoints
|
||||
curl -s "http://localhost:8080/asteroid/api/icecast-status" > /dev/null 2>&1 || true
|
||||
sleep 30
|
||||
|
||||
# Test player page
|
||||
curl -s "http://localhost:8080/asteroid/player/" > /dev/null 2>&1 || true
|
||||
sleep 30
|
||||
done
|
||||
} &
|
||||
|
||||
# Wait for monitoring to complete
|
||||
wait $MONITOR_PID
|
||||
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "=== TEST SUMMARY ===" | tee -a "$LOG_FILE"
|
||||
echo "Stream Type: $STREAM_DESC" | tee -a "$LOG_FILE"
|
||||
echo "Test completed at: $(date)" | tee -a "$LOG_FILE"
|
||||
echo "Log files created:" | tee -a "$LOG_FILE"
|
||||
echo " - Main log: $LOG_FILE" | tee -a "$LOG_FILE"
|
||||
echo " - CSV data: $LOGS_DIR/${LOG_PREFIX}_data_${TIMESTAMP}.csv" | tee -a "$LOG_FILE"
|
||||
echo " - Asteroid log: $LOGS_DIR/${LOG_PREFIX}_asteroid_${TIMESTAMP}.log" | tee -a "$LOG_FILE"
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "To run next test, switch stream format and run:" | tee -a "$LOG_FILE"
|
||||
echo " ./comprehensive-performance-test.sh [aac|mp3-high|mp3-low]" | tee -a "$LOG_FILE"
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
;;;; conditions.lisp - Custom error conditions for Asteroid Radio
|
||||
;;;; Provides a hierarchy of error conditions for better error handling and debugging
|
||||
|
||||
(in-package :asteroid)
|
||||
|
||||
;;; Base Condition Hierarchy
|
||||
|
||||
(define-condition asteroid-error (error)
|
||||
((message
|
||||
:initarg :message
|
||||
:reader error-message
|
||||
:documentation "Human-readable error message"))
|
||||
(:documentation "Base condition for all Asteroid-specific errors")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Asteroid Error: ~a" (error-message condition)))))
|
||||
|
||||
;;; Specific Error Types
|
||||
|
||||
(define-condition database-error (asteroid-error)
|
||||
((operation
|
||||
:initarg :operation
|
||||
:reader error-operation
|
||||
:initform nil
|
||||
:documentation "Database operation that failed (e.g., 'select', 'insert')"))
|
||||
(:documentation "Signaled when a database operation fails")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Database Error~@[ during ~a~]: ~a"
|
||||
(error-operation condition)
|
||||
(error-message condition)))))
|
||||
|
||||
(define-condition authentication-error (asteroid-error)
|
||||
((user
|
||||
:initarg :user
|
||||
:reader error-user
|
||||
:initform nil
|
||||
:documentation "Username or user ID that failed authentication"))
|
||||
(:documentation "Signaled when authentication fails")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Authentication Error~@[ for user ~a~]: ~a"
|
||||
(error-user condition)
|
||||
(error-message condition)))))
|
||||
|
||||
(define-condition authorization-error (asteroid-error)
|
||||
((required-role
|
||||
:initarg :required-role
|
||||
:reader error-required-role
|
||||
:initform nil
|
||||
:documentation "Role required for the operation"))
|
||||
(:documentation "Signaled when user lacks required permissions")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Authorization Error~@[ (requires ~a)~]: ~a"
|
||||
(error-required-role condition)
|
||||
(error-message condition)))))
|
||||
|
||||
(define-condition not-found-error (asteroid-error)
|
||||
((resource-type
|
||||
:initarg :resource-type
|
||||
:reader error-resource-type
|
||||
:initform nil
|
||||
:documentation "Type of resource that wasn't found (e.g., 'track', 'user')")
|
||||
(resource-id
|
||||
:initarg :resource-id
|
||||
:reader error-resource-id
|
||||
:initform nil
|
||||
:documentation "ID of the resource that wasn't found"))
|
||||
(:documentation "Signaled when a requested resource doesn't exist")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Not Found~@[ (~a~@[ ~a~])~]: ~a"
|
||||
(error-resource-type condition)
|
||||
(error-resource-id condition)
|
||||
(error-message condition)))))
|
||||
|
||||
(define-condition validation-error (asteroid-error)
|
||||
((field
|
||||
:initarg :field
|
||||
:reader error-field
|
||||
:initform nil
|
||||
:documentation "Field that failed validation"))
|
||||
(:documentation "Signaled when input validation fails")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Validation Error~@[ in field ~a~]: ~a"
|
||||
(error-field condition)
|
||||
(error-message condition)))))
|
||||
|
||||
(define-condition asteroid-stream-error (asteroid-error)
|
||||
((stream-type
|
||||
:initarg :stream-type
|
||||
:reader error-stream-type
|
||||
:initform nil
|
||||
:documentation "Type of stream (e.g., 'icecast', 'liquidsoap')"))
|
||||
(:documentation "Signaled when stream operations fail")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Stream Error~@[ (~a)~]: ~a"
|
||||
(error-stream-type condition)
|
||||
(error-message condition)))))
|
||||
|
||||
;;; Error Handling Macros
|
||||
|
||||
(defmacro with-error-handling (&body body)
|
||||
"Wrap API endpoint code with standard error handling.
|
||||
Catches specific Asteroid errors and returns appropriate HTTP status codes.
|
||||
|
||||
Usage:
|
||||
(define-api my-endpoint () ()
|
||||
(with-error-handling
|
||||
(do-something-that-might-fail)))"
|
||||
`(handler-case
|
||||
(progn ,@body)
|
||||
(not-found-error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(error-message e)))
|
||||
:status 404))
|
||||
(authentication-error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(error-message e)))
|
||||
:status 401))
|
||||
(authorization-error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(error-message e)))
|
||||
:status 403))
|
||||
(validation-error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(error-message e)))
|
||||
:status 400))
|
||||
(database-error (e)
|
||||
(format t "Database error: ~a~%" e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Database operation failed"))
|
||||
:status 500))
|
||||
(asteroid-stream-error (e)
|
||||
(format t "Stream error: ~a~%" e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Stream operation failed"))
|
||||
:status 500))
|
||||
(asteroid-error (e)
|
||||
(format t "Asteroid error: ~a~%" e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(error-message e)))
|
||||
:status 500))
|
||||
(error (e)
|
||||
(format t "Unexpected error: ~a~%" e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "An unexpected error occurred"))
|
||||
:status 500))))
|
||||
|
||||
(defmacro with-db-error-handling (operation &body body)
|
||||
"Wrap database operations with error handling.
|
||||
Automatically converts database errors to database-error conditions.
|
||||
|
||||
Usage:
|
||||
(with-db-error-handling \"select\"
|
||||
(db:select 'tracks (db:query :all)))"
|
||||
`(handler-case
|
||||
(progn ,@body)
|
||||
(error (e)
|
||||
(error 'database-error
|
||||
:message (format nil "~a" e)
|
||||
:operation ,operation))))
|
||||
|
||||
;;; Helper Functions
|
||||
|
||||
(defun signal-not-found (resource-type resource-id)
|
||||
"Signal a not-found-error with the given resource information."
|
||||
(error 'not-found-error
|
||||
:message (format nil "~a not found" resource-type)
|
||||
:resource-type resource-type
|
||||
:resource-id resource-id))
|
||||
|
||||
(defun signal-validation-error (field message)
|
||||
"Signal a validation-error for the given field."
|
||||
(error 'validation-error
|
||||
:message message
|
||||
:field field))
|
||||
|
||||
(defun signal-auth-error (user message)
|
||||
"Signal an authentication-error for the given user."
|
||||
(error 'authentication-error
|
||||
:message message
|
||||
:user user))
|
||||
|
||||
(defun signal-authz-error (required-role message)
|
||||
"Signal an authorization-error with the required role."
|
||||
(error 'authorization-error
|
||||
:message message
|
||||
:required-role required-role))
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
;;;; Radiance PostgreSQL Configuration for Asteroid Radio
|
||||
;;;; This file configures Radiance to use PostgreSQL instead of the default database
|
||||
|
||||
(in-package #:radiance-user)
|
||||
|
||||
;; PostgreSQL Database Configuration
|
||||
(setf (config :database :connection)
|
||||
'(:type :postgres
|
||||
:host "localhost" ; Change to "asteroid-postgres" when running in Docker
|
||||
:port 5432
|
||||
:database "asteroid"
|
||||
:username "asteroid"
|
||||
:password "asteroid_db_2025"))
|
||||
|
||||
;; Alternative Docker configuration (uncomment when running Asteroid in Docker)
|
||||
;; (setf (config :database :connection)
|
||||
;; '(:type :postgres
|
||||
;; :host "asteroid-postgres"
|
||||
;; :port 5432
|
||||
;; :database "asteroid"
|
||||
;; :username "asteroid"
|
||||
;; :password "asteroid_db_2025"))
|
||||
|
||||
;; Session storage configuration
|
||||
(setf (config :session :storage) :database)
|
||||
(setf (config :session :timeout) 3600) ; 1 hour timeout
|
||||
|
||||
;; Cache configuration
|
||||
(setf (config :cache :storage) :memory)
|
||||
|
||||
;; Enable database connection pooling
|
||||
(setf (config :database :pool-size) 10)
|
||||
(setf (config :database :pool-timeout) 30)
|
||||
|
||||
(format t "~%✅ Radiance configured for PostgreSQL~%")
|
||||
(format t "Database: asteroid@localhost:5432~%")
|
||||
(format t "Connection pooling: enabled (10 connections)~%~%")
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
; meta (:version 1.0 :package "COMMON-LISP-USER")
|
||||
[hash-table equal
|
||||
(:sessions
|
||||
[hash-table equalp
|
||||
(#1="AC457BD7-3E40-469A-83FA-E805C1514C6D" [session:session #1#])])]
|
||||
|
|
@ -9,25 +9,43 @@ set("init.allow_root", true)
|
|||
# Set log level for debugging
|
||||
log.level.set(4)
|
||||
|
||||
# Audio buffering settings to prevent choppiness
|
||||
settings.frame.audio.samplerate.set(44100)
|
||||
settings.frame.audio.channels.set(2)
|
||||
settings.audio.converter.samplerate.libsamplerate.quality.set("best")
|
||||
|
||||
# Enable telnet server for remote control
|
||||
settings.server.telnet.set(true)
|
||||
settings.server.telnet.port.set(1234)
|
||||
settings.server.telnet.bind_addr.set("0.0.0.0")
|
||||
|
||||
# Create playlist source from mounted music directory
|
||||
# Create playlist source from generated M3U file
|
||||
# This file is managed by Asteroid's stream control system
|
||||
# Falls back to directory scan if playlist file doesn't exist
|
||||
radio = playlist(
|
||||
mode="normal", # Play in order (not randomized)
|
||||
reload=30, # Check for playlist updates every 30 seconds
|
||||
reload_mode="seconds", # Reload every N seconds (prevents running out of tracks)
|
||||
"/app/stream-queue.m3u"
|
||||
)
|
||||
|
||||
# Fallback to directory scan if playlist file is empty/missing
|
||||
radio_fallback = playlist.safe(
|
||||
mode="randomize",
|
||||
reload=3600,
|
||||
reload_mode="watch",
|
||||
"/app/music/"
|
||||
)
|
||||
|
||||
# Add some audio processing
|
||||
radio = amplify(1.0, radio)
|
||||
radio = normalize(radio)
|
||||
# Use main playlist, fall back to directory scan
|
||||
radio = fallback(track_sensitive=false, [radio, radio_fallback])
|
||||
|
||||
# Add crossfade between tracks
|
||||
radio = crossfade(radio)
|
||||
# Simple crossfade for smooth transitions
|
||||
radio = crossfade(
|
||||
duration=3.0, # 3 second crossfade
|
||||
fade_in=2.0, # 2 second fade in
|
||||
fade_out=2.0, # 2 second fade out
|
||||
radio
|
||||
)
|
||||
|
||||
# Create a fallback with emergency content
|
||||
emergency = sine(440.0)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
services:
|
||||
asteroid:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: Dockerfile.asteroid
|
||||
image: asteroid/app
|
||||
container_name: asteroid
|
||||
environment:
|
||||
- ASTEROID_STREAM_URL=${ASTEROID_STREAM_URL:-http://localhost:8000}
|
||||
volumes:
|
||||
- ${MUSIC_LIBRARY:-../music/library}:/app/music/library:ro
|
||||
- ${QUEUE_PLAYLIST:-../stream-queue.m3u}:/app/stream-queue.m3u
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
|
|
@ -24,12 +24,38 @@ services:
|
|||
depends_on:
|
||||
- icecast
|
||||
volumes:
|
||||
- ./music:/app/music:ro
|
||||
- ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
|
||||
- ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
|
||||
- ${QUEUE_PLAYLIST:-../stream-queue.m3u}:/app/stream-queue.m3u:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- asteroid-network
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: asteroid-postgres
|
||||
environment:
|
||||
POSTGRES_DB: asteroid
|
||||
POSTGRES_USER: asteroid
|
||||
POSTGRES_PASSWORD: asteroid_db_2025
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- asteroid-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U asteroid"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
asteroid-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
-- Asteroid Radio Database Initialization Script
|
||||
-- PostgreSQL Schema for persistent storage
|
||||
|
||||
-- Enable UUID extension for generating unique IDs
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'listener',
|
||||
active BOOLEAN DEFAULT true,
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
CONSTRAINT valid_role CHECK (role IN ('listener', 'dj', 'admin'))
|
||||
);
|
||||
|
||||
-- Create index on username and email for faster lookups
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
|
||||
-- Tracks table
|
||||
CREATE TABLE IF NOT EXISTS tracks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
artist VARCHAR(500),
|
||||
album VARCHAR(500),
|
||||
duration INTEGER DEFAULT 0,
|
||||
format VARCHAR(50),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
play_count INTEGER DEFAULT 0,
|
||||
added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_played TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for common queries
|
||||
CREATE INDEX idx_tracks_artist ON tracks(artist);
|
||||
CREATE INDEX idx_tracks_album ON tracks(album);
|
||||
CREATE INDEX idx_tracks_title ON tracks(title);
|
||||
|
||||
-- Playlists table
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index on user_id for faster user playlist lookups
|
||||
CREATE INDEX idx_playlists_user_id ON playlists(user_id);
|
||||
|
||||
-- Playlist tracks junction table (many-to-many relationship)
|
||||
CREATE TABLE IF NOT EXISTS playlist_tracks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(playlist_id, track_id, position)
|
||||
);
|
||||
|
||||
-- Create indexes for playlist track queries
|
||||
CREATE INDEX idx_playlist_tracks_playlist_id ON playlist_tracks(playlist_id);
|
||||
CREATE INDEX idx_playlist_tracks_track_id ON playlist_tracks(track_id);
|
||||
|
||||
-- Sessions table (for Radiance session management)
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
data JSONB,
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Create index on user_id and expires_at
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
-- Create default admin user (password: admin - CHANGE THIS!)
|
||||
-- Password hash for 'admin' using bcrypt
|
||||
INSERT INTO users (username, email, password_hash, role, active)
|
||||
VALUES ('admin', 'admin@asteroid.radio', '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqYqYqYqYq', 'admin', true)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- Create a test listener user
|
||||
INSERT INTO users (username, email, password_hash, role, active)
|
||||
VALUES ('listener', 'listener@asteroid.radio', '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqYqYqYqYq', 'listener', true)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- Grant necessary permissions
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO asteroid;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO asteroid;
|
||||
|
||||
-- Create function to update modified_date automatically
|
||||
CREATE OR REPLACE FUNCTION update_modified_date()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.modified_date = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for playlists table
|
||||
CREATE TRIGGER update_playlists_modified_date
|
||||
BEFORE UPDATE ON playlists
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_date();
|
||||
|
||||
-- Success message
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Asteroid Radio database initialized successfully!';
|
||||
RAISE NOTICE 'Database: asteroid';
|
||||
RAISE NOTICE 'User: asteroid';
|
||||
RAISE NOTICE 'Default admin user created: admin / admin (CHANGE PASSWORD!)';
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
; meta (:version 1.0 :package "RADIANCE-CORE")
|
||||
((:interfaces (:admin . "r-simple-admin") (:auth . "r-simple-auth")
|
||||
(:ban . "r-simple-ban") (:cache . "r-simple-cache")
|
||||
(:data-model . "r-simple-model") (:database . "i-lambdalite")
|
||||
(:relational-database . "i-sqlite") (:logger . "i-verbose")
|
||||
(:mail . "i-smtp") (:profile . "r-simple-profile") (:rate . "r-simple-rate")
|
||||
(:server . "i-hunchentoot") (:session . "r-simple-sessions")
|
||||
(:user . "r-simple-users"))
|
||||
(:versions
|
||||
. [hash-table equal ("radiance-core" :|2.2.0|) ("i-hunchentoot" :|1.1.0|)
|
||||
("asteroid" :|0.0.0|) ("i-log4cl" :|1.0.0|) ("r-clip" :|1.0.0|)
|
||||
("r-data-model" :|1.0.1|) ("i-lambdalite" :|1.0.0|)
|
||||
("r-simple-users" :|1.0.1|)
|
||||
("r-simple-errors" :|1.0.0|) ("i-verbose" :|1.0.0|)
|
||||
("r-simple-auth" :|1.0.0|) ("r-simple-sessions" :|1.0.1|)
|
||||
("r-ratify" :|1.0.0|) ("r-simple-rate" :|1.0.0|)
|
||||
("r-simple-profile" :|1.0.0|)])
|
||||
(:domains "radiance" "localhost")
|
||||
(:startup :r-simple-errors :r-simple-sessions) (:routes)
|
||||
(:debugger . :if-swank-connected))
|
||||
|
|
@ -33,12 +33,12 @@ Fade requested setting up Liquidsoap in Docker for the Asteroid Radio project, a
|
|||
|
||||
** Check Status
|
||||
#+BEGIN_SRC bash
|
||||
docker-compose ps
|
||||
docker compose ps
|
||||
#+END_SRC
|
||||
|
||||
** View Logs
|
||||
#+BEGIN_SRC bash
|
||||
docker-compose logs -f
|
||||
docker compose logs -f
|
||||
#+END_SRC
|
||||
|
||||
** Stop Services
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ docker compose ps
|
|||
|
||||
echo ""
|
||||
echo "🎵 Asteroid Radio is now streaming!"
|
||||
echo "📡 High Quality: http://localhost:8000/asteroid.mp3"
|
||||
echo "📡 Low Quality: http://localhost:8000/asteroid-low.mp3"
|
||||
echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3"
|
||||
echo "📡 High Quality AAC: http://localhost:8000/asteroid.aac"
|
||||
echo "📡 Low Quality MP3: http://localhost:8000/asteroid-low.mp3"
|
||||
echo "🔧 Admin Panel: http://localhost:8000/admin/"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,439 @@
|
|||
#+TITLE: Asteroid Radio - API Endpoints Reference
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
Asteroid Radio provides a comprehensive JSON API built with Radiance's =define-api= framework. All API endpoints return JSON responses and follow RESTful conventions.
|
||||
|
||||
** Base URL
|
||||
|
||||
All API endpoints are prefixed with =/api/asteroid/=
|
||||
|
||||
** Authentication
|
||||
|
||||
Protected endpoints require user authentication via session cookies. Unauthenticated requests to protected endpoints will return an error response.
|
||||
|
||||
** Response Format
|
||||
|
||||
All API responses follow this structure:
|
||||
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success" | "error",
|
||||
"message": "Human-readable message",
|
||||
"data": { ... } // Optional, endpoint-specific data
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
* Status Endpoints
|
||||
|
||||
** GET /api/asteroid/status
|
||||
|
||||
Get server status and system information.
|
||||
|
||||
*** Authentication
|
||||
Not required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"server": "asteroid-radio",
|
||||
"version": "1.0",
|
||||
"uptime": 3600
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** GET /api/asteroid/auth-status
|
||||
|
||||
Check current authentication status.
|
||||
|
||||
*** Authentication
|
||||
Not required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"loggedIn": true,
|
||||
"username": "admin",
|
||||
"role": "admin"
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** GET /api/asteroid/icecast-status
|
||||
|
||||
Get Icecast streaming server status and current track information.
|
||||
|
||||
*** Authentication
|
||||
Not required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"icestats": {
|
||||
"source": {
|
||||
"title": "Artist - Track Name",
|
||||
"listeners": 5,
|
||||
"genre": "Electronic",
|
||||
"bitrate": 128
|
||||
}
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
* Track Endpoints
|
||||
|
||||
** GET /api/asteroid/tracks
|
||||
|
||||
Get list of all tracks in the music library.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"tracks": [
|
||||
{
|
||||
"id": "track-id-123",
|
||||
"title": "Track Name",
|
||||
"artist": "Artist Name",
|
||||
"album": "Album Name",
|
||||
"duration": 245,
|
||||
"format": "mp3"
|
||||
}
|
||||
]
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** GET /api/asteroid/admin/tracks
|
||||
|
||||
Get administrative track listing (admin only).
|
||||
|
||||
*** Authentication
|
||||
Required (Admin role)
|
||||
|
||||
*** Response
|
||||
Same as =/api/asteroid/tracks= but includes additional metadata for administration.
|
||||
|
||||
* Player Control Endpoints
|
||||
|
||||
** GET /api/asteroid/player/status
|
||||
|
||||
Get current player status.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"player": {
|
||||
"state": "playing" | "paused" | "stopped",
|
||||
"currentTrack": {
|
||||
"id": "track-id-123",
|
||||
"title": "Track Name",
|
||||
"artist": "Artist Name"
|
||||
},
|
||||
"position": 45,
|
||||
"duration": 245
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** POST /api/asteroid/player/play
|
||||
|
||||
Play a specific track.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Parameters
|
||||
- =track-id= (required) - ID of the track to play
|
||||
|
||||
*** Example Request
|
||||
#+BEGIN_SRC bash
|
||||
curl -X POST http://localhost:8080/api/asteroid/player/play \
|
||||
-d "track-id=track-id-123"
|
||||
#+END_SRC
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Playing track",
|
||||
"player": {
|
||||
"state": "playing",
|
||||
"currentTrack": { ... }
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** POST /api/asteroid/player/pause
|
||||
|
||||
Pause current playback.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Playback paused",
|
||||
"player": {
|
||||
"state": "paused"
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** POST /api/asteroid/player/stop
|
||||
|
||||
Stop current playback.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Playback stopped",
|
||||
"player": {
|
||||
"state": "stopped"
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** POST /api/asteroid/player/resume
|
||||
|
||||
Resume paused playback.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Playback resumed",
|
||||
"player": {
|
||||
"state": "playing"
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
* Playlist Endpoints
|
||||
|
||||
** GET /api/asteroid/playlists
|
||||
|
||||
Get all playlists for the current user.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"playlists": [
|
||||
{
|
||||
"id": "playlist-id-123",
|
||||
"name": "My Playlist",
|
||||
"description": "Favorite tracks",
|
||||
"trackCount": 15,
|
||||
"created": "2025-10-10T12:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** POST /api/asteroid/playlists/create
|
||||
|
||||
Create a new playlist.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Parameters
|
||||
- =name= (required) - Playlist name
|
||||
- =description= (optional) - Playlist description
|
||||
|
||||
*** Example Request
|
||||
#+BEGIN_SRC bash
|
||||
curl -X POST http://localhost:8080/api/asteroid/playlists/create \
|
||||
-d "name=My Playlist&description=Favorite tracks"
|
||||
#+END_SRC
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Playlist created successfully",
|
||||
"playlist": {
|
||||
"id": "playlist-id-123",
|
||||
"name": "My Playlist",
|
||||
"description": "Favorite tracks"
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** GET /api/asteroid/playlists/get
|
||||
|
||||
Get details of a specific playlist.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Parameters
|
||||
- =playlist-id= (required) - ID of the playlist
|
||||
|
||||
*** Example Request
|
||||
#+BEGIN_SRC bash
|
||||
curl "http://localhost:8080/api/asteroid/playlists/get?playlist-id=playlist-id-123"
|
||||
#+END_SRC
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"playlist": {
|
||||
"id": "playlist-id-123",
|
||||
"name": "My Playlist",
|
||||
"description": "Favorite tracks",
|
||||
"tracks": [
|
||||
{
|
||||
"id": "track-id-123",
|
||||
"title": "Track Name",
|
||||
"artist": "Artist Name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** POST /api/asteroid/playlists/add-track
|
||||
|
||||
Add a track to a playlist.
|
||||
|
||||
*** Authentication
|
||||
Required
|
||||
|
||||
*** Parameters
|
||||
- =playlist-id= (required) - ID of the playlist
|
||||
- =track-id= (required) - ID of the track to add
|
||||
|
||||
*** Example Request
|
||||
#+BEGIN_SRC bash
|
||||
curl -X POST http://localhost:8080/api/asteroid/playlists/add-track \
|
||||
-d "playlist-id=playlist-id-123&track-id=track-id-456"
|
||||
#+END_SRC
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Track added to playlist"
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
* Admin Endpoints
|
||||
|
||||
** POST /api/asteroid/admin/scan-library
|
||||
|
||||
Scan the music library for new tracks.
|
||||
|
||||
*** Authentication
|
||||
Required (Admin role)
|
||||
|
||||
*** Response
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Library scan initiated",
|
||||
"tracksFound": 42
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
* Error Responses
|
||||
|
||||
All endpoints may return error responses in this format:
|
||||
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Description of the error"
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** Common HTTP Status Codes
|
||||
|
||||
- =200= - Success
|
||||
- =400= - Bad Request (missing or invalid parameters)
|
||||
- =401= - Unauthorized (authentication required)
|
||||
- =403= - Forbidden (insufficient permissions)
|
||||
- =404= - Not Found (resource doesn't exist)
|
||||
- =500= - Internal Server Error
|
||||
|
||||
* Testing API Endpoints
|
||||
|
||||
** Using curl
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# Get server status
|
||||
curl http://localhost:8080/api/asteroid/status
|
||||
|
||||
# Get auth status
|
||||
curl http://localhost:8080/api/asteroid/auth-status
|
||||
|
||||
# Get tracks (requires authentication)
|
||||
curl -b cookies.txt http://localhost:8080/api/asteroid/tracks
|
||||
|
||||
# Play a track
|
||||
curl -X POST -b cookies.txt http://localhost:8080/api/asteroid/player/play \
|
||||
-d "track-id=123"
|
||||
#+END_SRC
|
||||
|
||||
** Using the Test Suite
|
||||
|
||||
The project includes a comprehensive test suite:
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
./test-server.sh
|
||||
#+END_SRC
|
||||
|
||||
See =docs/TESTING.org= for details.
|
||||
|
||||
* Browser Detection
|
||||
|
||||
API endpoints support a =browser= parameter for dual usage (API + browser):
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# API usage - returns JSON
|
||||
curl -X POST http://localhost:8080/api/asteroid/playlists/create \
|
||||
-d "name=Test"
|
||||
|
||||
# Browser usage - redirects to page
|
||||
curl -X POST http://localhost:8080/api/asteroid/playlists/create \
|
||||
-d "name=Test&browser=true"
|
||||
#+END_SRC
|
||||
|
||||
When =browser=true= is passed, endpoints will redirect to appropriate pages instead of returning JSON.
|
||||
|
||||
* Rate Limiting
|
||||
|
||||
API endpoints implement rate limiting to prevent abuse. Excessive requests may result in temporary blocking.
|
||||
|
||||
* Future Enhancements
|
||||
|
||||
Planned API improvements:
|
||||
|
||||
- WebSocket support for real-time updates
|
||||
- Pagination for large result sets
|
||||
- Advanced search and filtering
|
||||
- Batch operations
|
||||
- API versioning
|
||||
- OAuth2 authentication option
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
#+TITLE: Asteroid Radio - API Reference
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Current Interfaces
|
||||
|
||||
Asteroid Radio provides multiple interfaces for different purposes:
|
||||
|
||||
1. **REST API** - JSON API for web application and programmatic access (see [[file:API-ENDPOINTS.org][API Endpoints Reference]])
|
||||
2. **Streaming Endpoints** - Direct audio stream access via Icecast2
|
||||
3. **Icecast Admin** - Web-based streaming server administration
|
||||
4. **Liquidsoap Control** - Telnet interface for DJ controls
|
||||
5. **Docker Management** - Container orchestration and management
|
||||
|
||||
** Available Interfaces
|
||||
|
||||
*** Streaming Endpoints
|
||||
- **High Quality MP3**: http://localhost:8000/asteroid.mp3 (128kbps)
|
||||
- **High Quality AAC**: http://localhost:8000/asteroid.aac (96kbps)
|
||||
- **Low Quality MP3**: http://localhost:8000/asteroid-low.mp3 (64kbps)
|
||||
|
||||
*** Administrative Interfaces
|
||||
- **Icecast Admin**: http://localhost:8000/admin/ (admin/asteroid_admin_2024)
|
||||
- **Liquidsoap Control**: =telnet localhost 1234= (telnet interface)
|
||||
|
||||
* Streaming Interface
|
||||
|
||||
** Stream Access
|
||||
All streams are accessible via standard HTTP and can be played in any media player that supports internet radio streams.
|
||||
|
||||
*** Testing Stream Connectivity
|
||||
#+BEGIN_SRC bash
|
||||
# Test all three streams
|
||||
curl -I http://localhost:8000/asteroid.mp3 # 128kbps MP3
|
||||
curl -I http://localhost:8000/asteroid.aac # 96kbps AAC
|
||||
curl -I http://localhost:8000/asteroid-low.mp3 # 64kbps MP3
|
||||
#+END_SRC
|
||||
|
||||
*** Playing Streams
|
||||
#+BEGIN_SRC bashfutu
|
||||
# With VLC
|
||||
vlc http://localhost:8000/asteroid.mp3
|
||||
|
||||
# With mpv
|
||||
mpv http://localhost:8000/asteroid.aac
|
||||
|
||||
# With curl (save to file)
|
||||
curl http://localhost:8000/asteroid-low.mp3 > stream.mp3
|
||||
#+END_SRC
|
||||
|
||||
* Icecast Admin Interface
|
||||
|
||||
** Web Administration
|
||||
Access the Icecast admin interface at http://localhost:8000/admin/
|
||||
|
||||
*** Login Credentials
|
||||
- **Username**: admin
|
||||
- **Password**: asteroid_admin_2024
|
||||
|
||||
*** Available Functions
|
||||
- **Stream Status**: View current streams and listener counts
|
||||
- **Mount Points**: Manage stream mount points
|
||||
- **Listener Statistics**: Real-time listener data
|
||||
- **Server Configuration**: View server settings
|
||||
- **Log Files**: Access server logs
|
||||
|
||||
** Icecast Status XML
|
||||
Get server status in XML format:
|
||||
#+BEGIN_SRC bash
|
||||
curl http://localhost:8000/admin/stats.xml
|
||||
#+END_SRC
|
||||
|
||||
** Stream Statistics
|
||||
Get individual stream stats:
|
||||
#+BEGIN_SRC bash
|
||||
curl http://localhost:8000/admin/stats.xml?mount=/asteroid.mp3
|
||||
curl http://localhost:8000/admin/stats.xml?mount=/asteroid.aac
|
||||
curl http://localhost:8000/admin/stats.xml?mount=/asteroid-low.mp3
|
||||
#+END_SRC
|
||||
|
||||
* Liquidsoap Control Interface
|
||||
|
||||
** Telnet Access
|
||||
Connect to Liquidsoap's telnet interface for real-time control:
|
||||
#+BEGIN_SRC bash
|
||||
telnet localhost 1234
|
||||
#+END_SRC
|
||||
|
||||
** Available Commands
|
||||
Once connected via telnet, you can use these commands:
|
||||
|
||||
*** Basic Information
|
||||
#+BEGIN_SRC
|
||||
help # List all available commands
|
||||
version # Show Liquidsoap version
|
||||
uptime # Show server uptime
|
||||
#+END_SRC
|
||||
|
||||
*** Source Control
|
||||
#+BEGIN_SRC
|
||||
request.queue # Show current queue
|
||||
request.push <uri> # Add track to queue
|
||||
request.skip # Skip current track
|
||||
#+END_SRC
|
||||
|
||||
*** Metadata
|
||||
#+BEGIN_SRC
|
||||
request.metadata # Show current track metadata
|
||||
request.on_air # Show what's currently playing
|
||||
#+END_SRC
|
||||
|
||||
*** Volume and Audio
|
||||
#+BEGIN_SRC
|
||||
var.get amplify # Get current amplification level
|
||||
var.set amplify 1.2 # Set amplification level
|
||||
#+END_SRC
|
||||
|
||||
** Telnet Scripting
|
||||
You can script Liquidsoap commands:
|
||||
#+BEGIN_SRC bash
|
||||
# Get current track info
|
||||
echo "request.metadata" | nc localhost 1234
|
||||
|
||||
# Skip current track
|
||||
echo "request.skip" | nc localhost 1234
|
||||
|
||||
# Check queue status
|
||||
echo "request.queue" | nc localhost 1234
|
||||
#+END_SRC
|
||||
|
||||
* Docker Container Management
|
||||
|
||||
** Container Status
|
||||
#+BEGIN_SRC bash
|
||||
# Check running containers
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs icecast
|
||||
docker compose logs liquidsoap
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
#+END_SRC
|
||||
|
||||
** Music Library Management
|
||||
#+BEGIN_SRC bash
|
||||
# Add music files (container will detect automatically)
|
||||
cp ~/path/to/music/*.mp3 docker/music/
|
||||
cp ~/path/to/music/*.flac docker/music/
|
||||
|
||||
# Check what Liquidsoap is seeing
|
||||
echo "request.queue" | nc localhost 1234
|
||||
#+END_SRC
|
||||
|
||||
* REST API
|
||||
|
||||
Asteroid Radio includes a comprehensive REST API built with Radiance's =define-api= framework.
|
||||
|
||||
** API Documentation
|
||||
|
||||
For complete REST API documentation, see **[[file:API-ENDPOINTS.org][API Endpoints Reference]]**.
|
||||
|
||||
The API provides:
|
||||
- **Authentication & User Management** - Login, registration, user administration
|
||||
- **Track Management** - Browse and search music library
|
||||
- **Playlist Operations** - Create, manage, and play playlists
|
||||
- **Player Control** - Play, pause, stop, resume playback
|
||||
- **Admin Functions** - Library scanning, system management
|
||||
|
||||
** Quick API Examples
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# Get server status
|
||||
curl http://localhost:8080/api/asteroid/status
|
||||
|
||||
# Get authentication status
|
||||
curl http://localhost:8080/api/asteroid/auth-status
|
||||
|
||||
# Get Icecast streaming status
|
||||
curl http://localhost:8080/api/asteroid/icecast-status
|
||||
|
||||
# Get tracks (requires authentication)
|
||||
curl -b cookies.txt http://localhost:8080/api/asteroid/tracks
|
||||
#+END_SRC
|
||||
|
||||
See **[[file:API-ENDPOINTS.org][API Endpoints Reference]]** for complete documentation of all 15+ endpoints.
|
||||
|
||||
* Getting Help
|
||||
|
||||
For support with interfaces and streaming setup:
|
||||
- Check project documentation and troubleshooting guides
|
||||
- Review Docker container logs for error messages
|
||||
- Join our IRC chat room: **#asteroid.music** on **irc.libera.chat**
|
||||
- Submit issues with detailed system information
|
||||
|
||||
This interface reference covers the streaming infrastructure interfaces. For the REST API, see **[[file:API-ENDPOINTS.org][API Endpoints Reference]]**.
|
||||
|
|
@ -0,0 +1,540 @@
|
|||
#+TITLE: Asteroid Radio - Development Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Development Setup
|
||||
|
||||
#+BEGIN_QUOTE
|
||||
*Note on Package Managers*: Examples use =apt= (Debian/Ubuntu). Replace with your distribution's package manager (=dnf=, =pacman=, =zypper=, =apk=, etc.).
|
||||
#+END_QUOTE
|
||||
|
||||
** Prerequisites
|
||||
|
||||
*** System Dependencies
|
||||
- SBCL (Steel Bank Common Lisp)
|
||||
- Quicklisp package manager
|
||||
- Git version control
|
||||
- Docker and Docker Compose
|
||||
- PostgreSQL (for production database)
|
||||
- taglib for metadata extraction
|
||||
|
||||
*** Lisp Dependencies (via Quicklisp)
|
||||
|
||||
All Lisp dependencies are automatically installed via Quicklisp when you run =(ql:quickload :asteroid)=:
|
||||
|
||||
**** Core Framework
|
||||
- =radiance= - Web framework and module system
|
||||
- =slynk= - SLIME/SLY development server
|
||||
- =i-log4cl= - Logging interface
|
||||
- =r-clip= - CLIP template processor (Radiance)
|
||||
- =r-simple-rate= - Rate limiting (Radiance)
|
||||
- =r-simple-profile= - User profiles (Radiance)
|
||||
- =r-data-model= - Data modeling (Radiance)
|
||||
|
||||
**** Utilities & Libraries
|
||||
- =lass= - CSS preprocessing in Lisp
|
||||
- =cl-json= - JSON encoding/decoding
|
||||
- =alexandria= - Common Lisp utilities
|
||||
- =local-time= - Time and date handling
|
||||
- =taglib= - Audio metadata extraction
|
||||
- =ironclad= - Cryptographic functions
|
||||
- =babel= - Character encoding conversion
|
||||
- =cl-fad= - File and directory operations
|
||||
- =bordeaux-threads= - Portable threading
|
||||
- =drakma= - HTTP client
|
||||
- =usocket= - Universal socket library
|
||||
- =cl-ppcre= - Perl-compatible regular expressions
|
||||
|
||||
**** Radiance Interfaces
|
||||
- =:auth= - Authentication interface
|
||||
- =:database= - Database interface
|
||||
- =:user= - User management interface
|
||||
|
||||
*** Ubuntu/Debian Installation
|
||||
#+BEGIN_SRC bash
|
||||
# Install system packages
|
||||
sudo apt update
|
||||
sudo apt install sbcl git docker.io docker-compose postgresql libtagc0-dev
|
||||
|
||||
# Add user to docker group
|
||||
sudo usermod -a -G docker $USER
|
||||
# Log out and back in for group changes to take effect
|
||||
|
||||
# Install Quicklisp (if not already installed)
|
||||
curl -O https://beta.quicklisp.org/quicklisp.lisp
|
||||
sbcl --load quicklisp.lisp --eval "(quicklisp-quickstart:install)" --quit
|
||||
|
||||
# Note: PostgreSQL runs in Docker for development
|
||||
# See docs/POSTGRESQL-SETUP.org for database configuration
|
||||
#+END_SRC
|
||||
|
||||
** Project Setup
|
||||
|
||||
*** Clone Repository
|
||||
#+BEGIN_SRC bash
|
||||
git clone https://github.com/fade/asteroid.git
|
||||
cd asteroid
|
||||
#+END_SRC
|
||||
|
||||
*** Install Lisp Dependencies
|
||||
#+BEGIN_SRC bash
|
||||
# Start SBCL and load the system
|
||||
sbcl
|
||||
(ql:quickload :asteroid)
|
||||
#+END_SRC
|
||||
|
||||
*** ASDF Configuration (Optional but Recommended)
|
||||
For easier development, configure ASDF to find the asteroid system:
|
||||
#+BEGIN_SRC bash
|
||||
# Create ASDF source registry configuration
|
||||
mkdir -p ~/.config/common-lisp
|
||||
cat > ~/.config/common-lisp/source-registry.conf
|
||||
;; -*-lisp-*-
|
||||
(:source-registry
|
||||
(:tree "/path/to/your/projects/")
|
||||
:inherit-configuration)
|
||||
#+END_SRC
|
||||
|
||||
This allows you to load the asteroid system from any directory without changing paths.
|
||||
|
||||
* Development Workflow
|
||||
|
||||
** Local Development Server
|
||||
|
||||
*** Starting Development Environment
|
||||
#+BEGIN_SRC bash
|
||||
# Start Docker streaming services
|
||||
cd docker/
|
||||
docker compose up -d
|
||||
|
||||
# Verify containers are running
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Start RADIANCE web server (local development)
|
||||
sbcl --eval "(ql:quickload :asteroid)" --eval "(asteroid:start-server)"
|
||||
#+END_SRC
|
||||
|
||||
*** Development URLs
|
||||
- *Web Interface*: http://localhost:8080/asteroid/
|
||||
- *Admin Panel*: http://localhost:8080/asteroid/admin
|
||||
- *User Management*: http://localhost:8080/asteroid/admin/users
|
||||
- *Web Player*: http://localhost:8080/asteroid/player
|
||||
- *API Base*: http://localhost:8080/api/asteroid/
|
||||
- *Live Stream*: http://localhost:8000/asteroid.mp3
|
||||
- *Icecast Admin*: http://localhost:8000/admin/ (admin/asteroid_admin_2024)
|
||||
|
||||
** Music Library Management
|
||||
|
||||
*** Directory Structure
|
||||
The music directory is located directly under the asteroid root directory:
|
||||
#+BEGIN_SRC
|
||||
asteroid/music/ # Music directory (can be symlink)
|
||||
├── artist1/
|
||||
│ ├── album1/
|
||||
│ │ ├── track1.mp3
|
||||
│ │ └── track2.flac
|
||||
│ └── album2/
|
||||
│ └── track3.ogg
|
||||
└── artist2/
|
||||
└── single.wav
|
||||
#+END_SRC
|
||||
|
||||
The =music/= directory can be:
|
||||
- A regular directory with music files
|
||||
- A symlink to your actual music collection
|
||||
- Multiple subdirectories or symlinks within it
|
||||
|
||||
*** Recursive Scanning Capabilities
|
||||
The Asteroid application includes built-in recursive directory scanning:
|
||||
- *Function*: =scan-music-library= in =stream-media.lisp=
|
||||
- *Supports*: MP3, FLAC, OGG, WAV formats
|
||||
- *Recursive*: Automatically scans all subdirectories
|
||||
- *Metadata*: Extracts title, artist, album, duration using taglib
|
||||
- *Database*: Stores track information in RADIANCE database
|
||||
|
||||
*** Adding Music to Development Environment
|
||||
#+BEGIN_SRC bash
|
||||
# Option 1: Copy music files directly
|
||||
cp -r /path/to/your/music/* music/
|
||||
|
||||
# Option 2: Symlink entire music directory
|
||||
ln -s /path/to/existing/music music
|
||||
|
||||
# Option 3: Symlink subdirectories within music/
|
||||
mkdir -p music
|
||||
ln -s /path/to/collection1 music/collection1
|
||||
ln -s /path/to/collection2 music/collection2
|
||||
|
||||
# Option 4: Mount remote directory (for large collections)
|
||||
# Edit docker-compose.yml to change volume mount:
|
||||
# volumes:
|
||||
# - /mnt/remote-music:/app/music:ro
|
||||
|
||||
# Trigger library scan via API
|
||||
curl -X POST http://localhost:8080/api/asteroid/admin/scan-library
|
||||
#+END_SRC
|
||||
|
||||
** Code Organization
|
||||
|
||||
*** Main Components
|
||||
- =asteroid.lisp= - Main server with RADIANCE routes and API endpoints
|
||||
- =asteroid.asd= - System definition with dependencies
|
||||
- =template/= - CLIP HTML templates for web interface
|
||||
- =static/= - CSS stylesheets and static assets
|
||||
- =asteroid-radio.liq= - Liquidsoap streaming configuration
|
||||
|
||||
*** Key Modules
|
||||
- *Web Routes*: RADIANCE framework with =#@= URL patterns
|
||||
- *Database*: RADIANCE DB abstraction for track metadata
|
||||
- *Streaming*: Docker containers with Icecast2 and Liquidsoap
|
||||
- *File Processing*: Metadata extraction and library management
|
||||
- *Docker Integration*: Containerized streaming infrastructure
|
||||
|
||||
** Development Practices
|
||||
|
||||
*** Code Style
|
||||
- Use 2-space indentation for Lisp code
|
||||
- Follow Common Lisp naming conventions
|
||||
- Document functions with docstrings
|
||||
- Use meaningful variable and function names
|
||||
|
||||
*** Database Development
|
||||
#+BEGIN_SRC lisp
|
||||
;; Always use quoted symbols for field names
|
||||
(db:select 'tracks (db:query (:= 'artist "Artist Name")))
|
||||
|
||||
;; Primary key is "_id" internally, "id" in JSON responses
|
||||
(gethash "_id" track-record)
|
||||
#+END_SRC
|
||||
|
||||
*** Template Development with CLIP
|
||||
|
||||
Asteroid Radio uses CLIP (Common Lisp HTML Processor) for templating. Templates are in the =template/= directory.
|
||||
|
||||
**** Custom =data-text= Attribute Processor
|
||||
|
||||
We define a custom CLIP attribute processor in =template-utils.lisp= for dynamic text replacement:
|
||||
|
||||
#+BEGIN_SRC lisp
|
||||
;; Defined in template-utils.lisp
|
||||
(clip:define-attribute-processor data-text (node value)
|
||||
"Process data-text attribute - replaces node text content with clipboard value"
|
||||
(plump:clear node)
|
||||
(plump:make-text-node node (clip:clipboard value)))
|
||||
#+END_SRC
|
||||
|
||||
**** Using =data-text= in Templates
|
||||
|
||||
In your HTML templates (=.chtml= files):
|
||||
|
||||
#+BEGIN_SRC html
|
||||
<!-- The data-text attribute gets replaced with the value from the plist -->
|
||||
<h1 data-text="page-title">Default Title</h1>
|
||||
<span data-text="username">Guest</span>
|
||||
<p data-text="status-message">Loading...</p>
|
||||
#+END_SRC
|
||||
|
||||
**** Rendering Templates from Lisp
|
||||
|
||||
In your route handlers:
|
||||
|
||||
#+BEGIN_SRC lisp
|
||||
(define-page my-page #@"/my-page" ()
|
||||
(render-template-with-plist "my-template"
|
||||
:page-title "My Page"
|
||||
:username (user:username (auth:current))
|
||||
:status-message "Ready"))
|
||||
#+END_SRC
|
||||
|
||||
**** How It Works
|
||||
|
||||
1. =render-template-with-plist= passes keyword arguments to CLIP
|
||||
2. CLIP processes the template and finds =data-text= attributes
|
||||
3. The custom processor replaces the node's text with the value from the "clipboard" (keyword args)
|
||||
4. Default text in the HTML is replaced with dynamic content
|
||||
|
||||
**** CLIP Documentation
|
||||
|
||||
- **CLIP GitHub**: https://github.com/Shinmera/clip
|
||||
- **Attribute Processors**: Custom processors extend CLIP's functionality
|
||||
- **Standard CLIP**: Uses =lquery= for more complex DOM manipulation
|
||||
- **Our Approach**: Simple =data-text= processor for most use cases
|
||||
|
||||
**** Template Development Tips
|
||||
|
||||
- Keep templates in =template/= directory
|
||||
- Use =data-text= for simple text replacement
|
||||
- Test template changes with browser refresh (templates are cached)
|
||||
- Clear cache during development: =(clear-template-cache)=
|
||||
- Maintain responsive design principles
|
||||
|
||||
*** CSS Development with LASS
|
||||
- CSS is generated dynamically from =static/asteroid.lass= using LASS (Lisp Augmented Style Sheets)
|
||||
- Edit the =.lass= file, not the generated =.css= file
|
||||
- CSS is automatically compiled when the server starts via =compile-styles= function
|
||||
- Use Lisp syntax for CSS: =(body :background "#0a0a0a" :color "#00ffff")=
|
||||
- Supports nested selectors, variables, and programmatic CSS generation
|
||||
|
||||
** Testing
|
||||
|
||||
*** Manual Testing Checklist
|
||||
- [ ] Web interface loads correctly
|
||||
- [ ] Admin panel functions work
|
||||
- [ ] File upload and processing works
|
||||
- [ ] Live stream plays audio
|
||||
- [ ] Database queries return expected results
|
||||
- [ ] API endpoints respond correctly
|
||||
|
||||
*** Docker Container Testing
|
||||
#+BEGIN_SRC bash
|
||||
# Check container status
|
||||
docker compose ps
|
||||
|
||||
# Test stream connectivity
|
||||
curl -I http://localhost:8000/asteroid.mp3
|
||||
|
||||
# Test with media player
|
||||
vlc http://localhost:8000/asteroid.mp3
|
||||
|
||||
# Check container logs
|
||||
docker compose logs icecast
|
||||
docker compose logs liquidsoap
|
||||
#+END_SRC
|
||||
|
||||
*** API Testing
|
||||
|
||||
Asteroid Radio includes a comprehensive automated test suite:
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# Run full test suite
|
||||
./test-server.sh
|
||||
|
||||
# Run with verbose output
|
||||
./test-server.sh -v
|
||||
|
||||
# Test specific endpoints manually
|
||||
curl http://localhost:8080/api/asteroid/status
|
||||
curl http://localhost:8080/api/asteroid/tracks
|
||||
curl -X POST http://localhost:8080/api/asteroid/player/play -d "track-id=123"
|
||||
#+END_SRC
|
||||
|
||||
See [[file:TESTING.org][Testing Guide]] for complete documentation.
|
||||
|
||||
*** API Endpoint Structure
|
||||
|
||||
All API endpoints use Radiance's =define-api= macro and follow this pattern:
|
||||
|
||||
- Base URL: =/api/asteroid/=
|
||||
- Response format: JSON
|
||||
- Authentication: Session-based for protected endpoints
|
||||
|
||||
See [[file:API-ENDPOINTS.org][API Endpoints Reference]] for complete API documentation.
|
||||
|
||||
** Debugging
|
||||
|
||||
*** Common Development Issues
|
||||
|
||||
**** Stream Not Playing
|
||||
- Check Docker container status: =docker compose ps=
|
||||
- Check Liquidsoap container logs: =docker compose logs liquidsoap=
|
||||
- Check Icecast2 container logs: =docker compose logs icecast=
|
||||
- Verify music files exist in =docker/music/library/=
|
||||
- Restart containers: =docker compose restart=
|
||||
|
||||
**** Database Errors
|
||||
- Ensure proper field name quoting in queries
|
||||
- Check RADIANCE database configuration
|
||||
- Verify database file permissions
|
||||
|
||||
**** Template Rendering Issues
|
||||
- Check CLIP template syntax
|
||||
- Verify template file paths
|
||||
- Test with simplified templates first
|
||||
|
||||
*** Debug Configuration
|
||||
#+BEGIN_SRC bash
|
||||
# Enable verbose logging in Docker containers
|
||||
# Edit docker/liquidsoap/asteroid-radio.liq
|
||||
settings.log.level := 4
|
||||
settings.log.stdout := true
|
||||
settings.log.file := true
|
||||
settings.log.file.path := "/var/log/liquidsoap/asteroid.log"
|
||||
|
||||
# View real-time container logs
|
||||
docker compose logs -f liquidsoap
|
||||
docker compose logs -f icecast
|
||||
#+END_SRC
|
||||
|
||||
** Contributing Guidelines
|
||||
|
||||
*** Branch Strategy
|
||||
- =main= - Stable production code
|
||||
- =develop= - Integration branch for new features
|
||||
- =feature/*= - Individual feature development
|
||||
- =bugfix/*= - Bug fixes and patches
|
||||
|
||||
*** Commit Messages
|
||||
- Use clear, descriptive commit messages
|
||||
- Reference issue numbers when applicable
|
||||
- Keep commits focused on single changes
|
||||
|
||||
*** Pull Request Process
|
||||
1. Create feature branch from =develop=
|
||||
2. Implement changes with tests
|
||||
3. Update documentation if needed
|
||||
4. Submit pull request with description
|
||||
5. Address code review feedback
|
||||
6. Merge after approval
|
||||
|
||||
*** Code Review Checklist
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Functions are properly documented
|
||||
- [ ] No hardcoded values or credentials
|
||||
- [ ] Error handling is appropriate
|
||||
- [ ] Performance considerations addressed
|
||||
|
||||
** Development Tools
|
||||
|
||||
*** Recommended Editor Setup
|
||||
- *Emacs*: SLIME for interactive Lisp development
|
||||
|
||||
*** Useful Development Commands
|
||||
#+BEGIN_SRC lisp
|
||||
;; Reload system during development
|
||||
(ql:quickload :asteroid :force t)
|
||||
|
||||
;; Restart RADIANCE server
|
||||
(radiance:shutdown)
|
||||
(asteroid:start-server)
|
||||
|
||||
;; Clear database for testing
|
||||
(db:drop 'tracks)
|
||||
(asteroid:setup-database)
|
||||
#+END_SRC
|
||||
|
||||
** Performance Considerations
|
||||
|
||||
*** Development vs Production
|
||||
- Use smaller music libraries in =docker/music/= for faster testing
|
||||
- Enable debug logging in Docker containers only when needed
|
||||
- Consider memory usage with large track collections in containers
|
||||
- Test with realistic concurrent user loads using Docker scaling
|
||||
- Use =docker compose.dev.yml= for development-specific settings
|
||||
|
||||
*** Optimization Tips
|
||||
- Cache database queries where appropriate
|
||||
- Optimize playlist generation for large libraries
|
||||
- Monitor memory usage during development
|
||||
- Profile streaming performance under load
|
||||
|
||||
* Configuration Files
|
||||
- =radiance-core.conf.lisp= - RADIANCE framework configuration
|
||||
- =docker/liquidsoap/asteroid-radio.liq= - Liquidsoap streaming setup
|
||||
- =docker/icecast.xml= - Icecast2 server configuration
|
||||
- =docker/docker-compose.yml= - Container orchestration
|
||||
|
||||
** Docker Development
|
||||
#+BEGIN_SRC bash
|
||||
# Start development containers
|
||||
cd docker/
|
||||
docker compose up -d
|
||||
|
||||
# Build development container with changes
|
||||
docker compose up --build
|
||||
|
||||
# Access container shell for debugging
|
||||
docker compose exec liquidsoap bash
|
||||
docker compose exec icecast bash
|
||||
|
||||
# Stop all containers
|
||||
docker compose down
|
||||
#+END_SRC
|
||||
|
||||
* Troubleshooting
|
||||
|
||||
** Development Environment Issues
|
||||
|
||||
*** SBCL/Quicklisp Problems
|
||||
- Ensure Quicklisp is properly installed
|
||||
- Check for conflicting Lisp installations
|
||||
- Verify system dependencies are installed
|
||||
|
||||
*** Docker Container Issues
|
||||
- Check container status: =docker compose ps=
|
||||
- Verify Docker daemon is running: =docker info=
|
||||
- Check container logs: =docker compose logs [service]=
|
||||
- Restart containers: =docker compose restart=
|
||||
|
||||
*** Network Access Issues
|
||||
- Check firewall settings for ports 8000, 8080
|
||||
- Verify WSL networking configuration if applicable
|
||||
- Test container networking: =docker compose exec liquidsoap ping icecast=
|
||||
- Check port binding: =docker compose port icecast 8000=
|
||||
|
||||
*** File Permission Issues
|
||||
- Ensure =docker/music/= directory is accessible
|
||||
- Check ownership: =ls -la docker/music/=
|
||||
- Fix permissions: =sudo chown -R $USER:$USER docker/music/=
|
||||
- Verify container volume mounts in =docker-compose.yml=
|
||||
- For remote mounts: ensure network storage is accessible
|
||||
|
||||
*** Music Library Issues
|
||||
- Check if music files exist: =find docker/music/ -name "*.mp3" -o -name "*.flac"=
|
||||
- Verify supported formats: MP3, FLAC, OGG, WAV
|
||||
- Test recursive scanning: =curl -X POST http://localhost:8080/asteroid/api/scan-library=
|
||||
- Check database for tracks: =curl http://localhost:8080/asteroid/api/tracks=
|
||||
- For large collections: avoid network mounts, use local storage (see memory about 175+ files causing timeouts)
|
||||
|
||||
** Getting Help
|
||||
- Check existing issues in project repository
|
||||
- Review RADIANCE framework documentation
|
||||
- Consult Liquidsoap manual for streaming issues
|
||||
- Join our IRC chat room: **#asteroid.music** on **irc.libera.chat**
|
||||
- Ask questions in project discussions
|
||||
|
||||
This development guide provides the foundation for contributing to Asteroid Radio. For deployment and production considerations, see the Installation Guide and Performance Testing documentation.
|
||||
|
||||
* Development Stack Links
|
||||
|
||||
** Core Technologies
|
||||
- **SBCL** (Steel Bank Common Lisp): https://www.sbcl.org/
|
||||
- **Quicklisp** (Common Lisp package manager): https://www.quicklisp.org/
|
||||
- **ASDF** (Another System Definition Facility): https://common-lisp.net/project/asdf/
|
||||
|
||||
** Web Framework & Libraries
|
||||
- **RADIANCE** (Web framework): https://shirakumo.github.io/radiance/
|
||||
- **CLIP** (HTML templating): https://shinmera.github.io/clip/
|
||||
- **LASS** (CSS in Lisp): https://shinmera.github.io/LASS/
|
||||
- **Alexandria** (Utility library): https://alexandria.common-lisp.dev/
|
||||
- **Local-Time** (Time handling): https://common-lisp.net/project/local-time/
|
||||
|
||||
** Audio & Streaming
|
||||
- **Docker** (Containerization): https://www.docker.com/
|
||||
- **Icecast2** (Streaming server): https://icecast.org/
|
||||
- **Liquidsoap** (Audio streaming): https://www.liquidsoap.info/
|
||||
- **TagLib** (Audio metadata): https://taglib.org/
|
||||
|
||||
** Database & Data
|
||||
- **cl-json** (JSON handling): https://common-lisp.net/project/cl-json/
|
||||
- **cl-fad** (File/directory utilities): https://edicl.github.io/cl-fad/
|
||||
- **Ironclad** (Cryptography): https://github.com/sharplispers/ironclad
|
||||
- **Babel** (Character encoding): https://common-lisp.net/project/babel/
|
||||
|
||||
** Development Tools
|
||||
- **Emacs** (Editor): https://www.gnu.org/software/emacs/
|
||||
- **SLIME** (Emacs Lisp IDE): https://common-lisp.net/project/slime/
|
||||
- **Slynk** (SLIME backend): https://github.com/joaotavora/sly
|
||||
- **Git** (Version control): https://git-scm.com/
|
||||
|
||||
** System Libraries
|
||||
- **Bordeaux-Threads** (Threading): https://common-lisp.net/project/bordeaux-threads/
|
||||
- **Drakma** (HTTP client): https://edicl.github.io/drakma/
|
||||
- **CIFS-Utils** (Network file systems): https://wiki.samba.org/index.php/LinuxCIFS_utils
|
||||
|
||||
** Documentation & Standards
|
||||
- **Common Lisp HyperSpec**: http://www.lispworks.com/documentation/HyperSpec/Front/
|
||||
- **Docker Compose**: https://docs.docker.com/compose/
|
||||
- **Org Mode** (Documentation format): https://orgmode.org/
|
||||
|
|
@ -0,0 +1,618 @@
|
|||
#+TITLE: Asteroid Radio - Docker Streaming Setup
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Docker Streaming Overview
|
||||
|
||||
This guide covers the complete Docker-based streaming setup for Asteroid Radio using Icecast2 and Liquidsoap containers. This approach provides a containerized, portable streaming infrastructure that's easy to deploy and maintain.
|
||||
|
||||
#+BEGIN_QUOTE
|
||||
*Note on Package Managers*: Examples use =apt= (Debian/Ubuntu). Replace with your distribution's package manager (=dnf=, =pacman=, =zypper=, =apk=, etc.).
|
||||
#+END_QUOTE
|
||||
|
||||
* Architecture
|
||||
|
||||
** Container Stack
|
||||
- *Icecast2 Container*: Streaming server handling client connections
|
||||
- *Liquidsoap Container*: Audio processing and stream generation
|
||||
- *Shared Volumes*: Music library and configuration sharing
|
||||
|
||||
** Stream Formats
|
||||
- *High Quality MP3*: 128kbps MP3 stream at /asteroid.mp3
|
||||
- *High Quality AAC*: 96kbps AAC stream at /asteroid.aac (better efficiency than MP3)
|
||||
- *Low Quality MP3*: 64kbps MP3 stream at /asteroid-low.mp3 (compatibility)
|
||||
|
||||
** Network Configuration
|
||||
- *Icecast2*: Port 8000 (streaming and admin)
|
||||
- *Liquidsoap Telnet*: Port 1234 (remote control)
|
||||
- *Internal Network*: Container-to-container communication
|
||||
|
||||
* Quick Start
|
||||
|
||||
** Prerequisites
|
||||
#+BEGIN_SRC bash
|
||||
# Install Docker and Docker Compose
|
||||
sudo apt update
|
||||
sudo apt install docker.io docker compose
|
||||
sudo usermod -a -G docker $USER
|
||||
# Log out and back in for group changes
|
||||
#+END_SRC
|
||||
|
||||
** One-Command Setup
|
||||
#+BEGIN_SRC bash
|
||||
# Clone and start
|
||||
git clone https://github.com/fade/asteroid asteroid-radio
|
||||
cd asteroid-radio/docker
|
||||
docker compose up -d
|
||||
#+END_SRC
|
||||
|
||||
** Verify Setup
|
||||
#+BEGIN_SRC bash
|
||||
# Check container status
|
||||
docker compose ps
|
||||
|
||||
# Test streaming (all three formats)
|
||||
curl -I http://localhost:8000/asteroid.mp3 # 128kbps MP3
|
||||
curl -I http://localhost:8000/asteroid.aac # 96kbps AAC
|
||||
curl -I http://localhost:8000/asteroid-low.mp3 # 64kbps MP3
|
||||
#+END_SRC
|
||||
|
||||
* Docker Compose Configuration
|
||||
|
||||
** Complete docker-compose.yml
|
||||
#+BEGIN_SRC yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
icecast:
|
||||
image: infiniteproject/icecast:latest
|
||||
container_name: asteroid-icecast
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./icecast.xml:/etc/icecast.xml
|
||||
environment:
|
||||
- ICECAST_SOURCE_PASSWORD=H1tn31EhsyLrfRmo
|
||||
- ICECAST_ADMIN_PASSWORD=asteroid_admin_2024
|
||||
- ICECAST_RELAY_PASSWORD=asteroid_relay_2024
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- asteroid-network
|
||||
|
||||
liquidsoap:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.liquidsoap
|
||||
container_name: asteroid-liquidsoap
|
||||
ports:
|
||||
- "1234:1234" # Telnet control port
|
||||
depends_on:
|
||||
- icecast
|
||||
volumes:
|
||||
- ./music:/app/music:ro
|
||||
- ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- asteroid-network
|
||||
|
||||
networks:
|
||||
asteroid-network:
|
||||
driver: bridge
|
||||
#+END_SRC
|
||||
|
||||
* Container Configurations
|
||||
|
||||
** Icecast2 Container Setup
|
||||
|
||||
*** Custom Icecast Configuration (icecast.xml)
|
||||
#+BEGIN_SRC xml
|
||||
<icecast>
|
||||
<location>Asteroid Radio Docker</location>
|
||||
<admin>admin@asteroid-radio.docker</admin>
|
||||
|
||||
<limits>
|
||||
<clients>100</clients>
|
||||
<sources>10</sources>
|
||||
<queue-size>524288</queue-size>
|
||||
<client-timeout>30</client-timeout>
|
||||
<header-timeout>15</header-timeout>
|
||||
<source-timeout>10</source-timeout>
|
||||
<burst-on-connect>1</burst-on-connect>
|
||||
</limits>
|
||||
|
||||
<authentication>
|
||||
<source-password>H1tn31EhsyLrfRmo</source-password>
|
||||
<relay-password>asteroid_relay_2024</relay-password>
|
||||
<admin-user>admin</admin-user>
|
||||
<admin-password>asteroid_admin_2024</admin-password>
|
||||
</authentication>
|
||||
|
||||
<hostname>icecast</hostname>
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
<bind-address>0.0.0.0</bind-address>
|
||||
</listen-socket>
|
||||
|
||||
<!-- High Quality Stream -->
|
||||
<mount type="normal">
|
||||
<mount-name>/asteroid.mp3</mount-name>
|
||||
<username>source</username>
|
||||
<password>H1tn31EhsyLrfRmo</password>
|
||||
<max-listeners>50</max-listeners>
|
||||
<public>1</public>
|
||||
<stream-name>Asteroid Radio - High Quality</stream-name>
|
||||
<stream-url>http://localhost:8080/asteroid/</stream-url>
|
||||
<genre>Electronic/Alternative</genre>
|
||||
<bitrate>128</bitrate>
|
||||
</mount>
|
||||
|
||||
<!-- AAC High Quality Stream -->
|
||||
<mount type="normal">
|
||||
<mount-name>/asteroid.aac</mount-name>
|
||||
<username>source</username>
|
||||
<password>H1tn31EhsyLrfRmo</password>
|
||||
<max-listeners>50</max-listeners>
|
||||
<public>1</public>
|
||||
<stream-name>Asteroid Radio - AAC</stream-name>
|
||||
<stream-description>Music for Hackers - 96kbps AAC</stream-description>
|
||||
<stream-url>http://localhost:8080/asteroid/</stream-url>
|
||||
<genre>Electronic/Alternative</genre>
|
||||
<bitrate>96</bitrate>
|
||||
</mount>
|
||||
|
||||
<!-- Low Quality Stream -->
|
||||
<mount type="normal">
|
||||
<mount-name>/asteroid-low.mp3</mount-name>
|
||||
<username>source</username>
|
||||
<password>H1tn31EhsyLrfRmo</password>
|
||||
<max-listeners>100</max-listeners>
|
||||
<public>1</public>
|
||||
<stream-name>Asteroid Radio - Low Quality</stream-name>
|
||||
<stream-description>Music for Hackers - 64kbps</stream-description>
|
||||
<stream-url>http://localhost:8080/asteroid/</stream-url>
|
||||
<genre>Electronic/Alternative</genre>
|
||||
<bitrate>64</bitrate>
|
||||
</mount>
|
||||
|
||||
<fileserve>1</fileserve>
|
||||
<paths>
|
||||
<basedir>/usr/share/icecast2</basedir>
|
||||
<logdir>/var/log/icecast2</logdir>
|
||||
<webroot>/usr/share/icecast2/web</webroot>
|
||||
<adminroot>/usr/share/icecast2/admin</adminroot>
|
||||
<alias source="/" destination="/status.xsl"/>
|
||||
</paths>
|
||||
|
||||
<logging>
|
||||
<accesslog>access.log</accesslog>
|
||||
<errorlog>error.log</errorlog>
|
||||
<loglevel>3</loglevel>
|
||||
<logsize>10000</logsize>
|
||||
</logging>
|
||||
</icecast>
|
||||
#+END_SRC
|
||||
|
||||
** Liquidsoap Container Setup
|
||||
|
||||
*** Liquidsoap Configuration (asteroid-radio-docker.liq)
|
||||
#+BEGIN_SRC liquidsoap
|
||||
#!/usr/bin/liquidsoap
|
||||
|
||||
# Asteroid Radio - Docker streaming script
|
||||
# Streams music library continuously to Icecast2 running in Docker
|
||||
|
||||
# Allow running as root in Docker
|
||||
set("init.allow_root", true)
|
||||
|
||||
# Set log level for debugging
|
||||
log.level.set(4)
|
||||
|
||||
# Enable telnet server for remote control
|
||||
settings.server.telnet.set(true)
|
||||
settings.server.telnet.port.set(1234)
|
||||
settings.server.telnet.bind_addr.set("0.0.0.0")
|
||||
|
||||
# Create playlist source from mounted music directory
|
||||
radio = playlist(
|
||||
mode="randomize",
|
||||
reload=3600,
|
||||
reload_mode="watch",
|
||||
"/app/music/"
|
||||
)
|
||||
|
||||
# Add some audio processing
|
||||
radio = amplify(1.0, radio)
|
||||
radio = normalize(radio)
|
||||
|
||||
# Add crossfade between tracks
|
||||
radio = crossfade(radio)
|
||||
|
||||
# Create a fallback with emergency content
|
||||
emergency = sine(440.0)
|
||||
emergency = amplify(0.1, emergency)
|
||||
|
||||
# Make source safe with fallback
|
||||
radio = fallback(track_sensitive=false, [radio, emergency])
|
||||
|
||||
# Add metadata
|
||||
radio = map_metadata(fun(m) ->
|
||||
[("title", m["title"] ?? "Unknown Track"),
|
||||
("artist", m["artist"] ?? "Unknown Artist"),
|
||||
("album", m["album"] ?? "Unknown Album")], radio)
|
||||
|
||||
# High Quality MP3 Stream (128kbps)
|
||||
output.icecast(
|
||||
%mp3(bitrate=128),
|
||||
host="icecast", # Docker service name
|
||||
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/",
|
||||
public=true,
|
||||
radio
|
||||
)
|
||||
|
||||
# AAC High Quality Stream (96kbps - better quality than 128kbps MP3)
|
||||
output.icecast(
|
||||
%fdkaac(bitrate=96),
|
||||
host="icecast",
|
||||
port=8000,
|
||||
password="H1tn31EhsyLrfRmo",
|
||||
mount="asteroid.aac",
|
||||
name="Asteroid Radio (AAC)",
|
||||
description="Music for Hackers - High efficiency AAC stream",
|
||||
genre="Electronic/Alternative",
|
||||
url="http://localhost:8080/asteroid/",
|
||||
public=true,
|
||||
radio
|
||||
)
|
||||
|
||||
# Low Quality MP3 Stream (for compatibility)
|
||||
output.icecast(
|
||||
%mp3(bitrate=64),
|
||||
host="icecast",
|
||||
port=8000,
|
||||
password="H1tn31EhsyLrfRmo",
|
||||
mount="asteroid-low.mp3",
|
||||
name="Asteroid Radio (Low Quality)",
|
||||
description="Music for Hackers - Low bandwidth stream",
|
||||
genre="Electronic/Alternative",
|
||||
url="http://localhost:8080/asteroid/",
|
||||
public=true,
|
||||
radio
|
||||
)
|
||||
|
||||
print("🎵 Asteroid Radio Docker streaming started!")
|
||||
print("High Quality MP3: http://localhost:8000/asteroid.mp3")
|
||||
print("High Quality AAC: http://localhost:8000/asteroid.aac")
|
||||
print("Low Quality MP3: http://localhost:8000/asteroid-low.mp3")
|
||||
print("Icecast Admin: http://localhost:8000/admin/")
|
||||
print("Telnet control: telnet localhost 1234")
|
||||
#+END_SRC
|
||||
|
||||
|
||||
* Management Scripts
|
||||
|
||||
** Start Script (start-streaming.sh)
|
||||
#+BEGIN_SRC bash
|
||||
#!/bin/bash
|
||||
|
||||
# Asteroid Radio Docker Streaming Startup Script
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting Asteroid Radio Docker Streaming..."
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "❌ Docker is not running. Please start Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create required directories
|
||||
mkdir -p music/incoming music/library logs
|
||||
|
||||
# Set permissions
|
||||
chmod 755 music/incoming music/library
|
||||
chmod 777 logs
|
||||
|
||||
# Pull latest images
|
||||
echo "📦 Pulling latest Docker images..."
|
||||
docker compose pull
|
||||
|
||||
# Start services
|
||||
echo "🎵 Starting streaming services..."
|
||||
docker compose up -d
|
||||
|
||||
# Wait for services to be ready
|
||||
echo "⏳ Waiting for services to start..."
|
||||
sleep 10
|
||||
|
||||
# Check service status
|
||||
echo "📊 Checking service status..."
|
||||
docker compose ps
|
||||
|
||||
# Test connectivity
|
||||
echo "🔍 Testing streaming connectivity..."
|
||||
if curl -s -I http://localhost:8000/asteroid.mp3 | grep -q "200 OK"; then
|
||||
echo "✅ High quality stream is working"
|
||||
else
|
||||
echo "⚠️ High quality stream may not be ready yet"
|
||||
fi
|
||||
|
||||
if curl -s -I http://localhost:8000/asteroid-low.mp3 | grep -q "200 OK"; then
|
||||
echo "✅ Low quality MP3 stream is working"
|
||||
else
|
||||
echo "⚠️ Low quality MP3 stream may not be ready yet"
|
||||
fi
|
||||
|
||||
if curl -s -I http://localhost:8000/asteroid.aac | grep -q "200 OK"; then
|
||||
echo "✅ AAC stream is working"
|
||||
else
|
||||
echo "⚠️ AAC stream may not be ready yet"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Asteroid Radio Docker setup complete!"
|
||||
echo ""
|
||||
echo "📻 Stream URLs:"
|
||||
echo " High Quality MP3: http://localhost:8000/asteroid.mp3 (128kbps)"
|
||||
echo " High Quality AAC: http://localhost:8000/asteroid.aac (96kbps)"
|
||||
echo " Low Quality MP3: http://localhost:8000/asteroid-low.mp3 (64kbps)"
|
||||
echo ""
|
||||
echo "🔧 Admin Interfaces:"
|
||||
echo " Icecast: http://localhost:8000/admin/ (admin/asteroid_admin_2024)"
|
||||
echo " Telnet: telnet localhost 1234"
|
||||
echo ""
|
||||
echo "📁 Add music files to: ./music/"
|
||||
echo " Files are automatically detected and streamed."
|
||||
#+END_SRC
|
||||
|
||||
** Stop Script (stop-streaming.sh)
|
||||
#+BEGIN_SRC bash
|
||||
#!/bin/bash
|
||||
|
||||
# Asteroid Radio Docker Streaming Stop Script
|
||||
|
||||
echo "🛑 Stopping Asteroid Radio Docker Streaming..."
|
||||
|
||||
# Stop all services
|
||||
docker compose down
|
||||
|
||||
# Optional: Remove volumes (uncomment to clean up completely)
|
||||
# docker compose down -v
|
||||
|
||||
echo "✅ All services stopped."
|
||||
#+END_SRC
|
||||
|
||||
** Test Script (test-streaming.sh)
|
||||
#+BEGIN_SRC bash
|
||||
#!/bin/bash
|
||||
|
||||
# Asteroid Radio Docker Streaming Test Script
|
||||
|
||||
echo "🧪 Testing Asteroid Radio Docker Setup..."
|
||||
|
||||
# Test container status
|
||||
echo "📊 Container Status:"
|
||||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "🔍 Testing Connectivity:"
|
||||
|
||||
# Test Icecast2
|
||||
if curl -s -I http://localhost:8000/ | grep -q "200 OK"; then
|
||||
echo "✅ Icecast2 server is responding"
|
||||
else
|
||||
echo "❌ Icecast2 server is not responding"
|
||||
fi
|
||||
|
||||
# Test high quality stream
|
||||
if curl -s -I http://localhost:8000/asteroid.mp3 | grep -q "200 OK"; then
|
||||
echo "✅ High quality stream is available"
|
||||
else
|
||||
echo "❌ High quality stream is not available"
|
||||
fi
|
||||
|
||||
# Test low quality stream
|
||||
if curl -s -I http://localhost:8000/asteroid-low.mp3 | grep -q "200 OK"; then
|
||||
echo "✅ Low quality MP3 stream is available"
|
||||
else
|
||||
echo "❌ Low quality MP3 stream is not available"
|
||||
fi
|
||||
|
||||
# Test AAC stream
|
||||
if curl -s -I http://localhost:8000/asteroid.aac | grep -q "200 OK"; then
|
||||
echo "✅ AAC stream is available"
|
||||
else
|
||||
echo "❌ AAC stream is not available"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📋 Service Logs (last 10 lines):"
|
||||
echo "--- Icecast2 ---"
|
||||
docker compose logs --tail=10 icecast
|
||||
|
||||
echo "--- Liquidsoap ---"
|
||||
docker compose logs --tail=10 liquidsoap
|
||||
|
||||
#+END_SRC
|
||||
|
||||
* Volume Management
|
||||
|
||||
** Music Library Setup
|
||||
#+BEGIN_SRC bash
|
||||
# Music directory already exists in repository
|
||||
# Copy sample music directly to the music directory
|
||||
cp ~/path/to/music/*.mp3 docker/music/
|
||||
|
||||
# Set permissions
|
||||
chmod 755 docker/music/
|
||||
sudo chown -R $USER:$USER docker/music/
|
||||
#+END_SRC
|
||||
|
||||
** Persistent Data
|
||||
- *Music Library*: =./music/= - Mounted as volume
|
||||
- *Logs*: =./logs/= - Container logs and streaming logs
|
||||
- *Configuration*: =./liquidsoap/= and =./icecast.xml= - Read-only configs
|
||||
|
||||
* Networking
|
||||
|
||||
** Internal Container Network
|
||||
- Containers communicate via =asteroid-network= bridge
|
||||
- Liquidsoap connects to Icecast using hostname =icecast=
|
||||
- Telnet control available on port 1234 for Liquidsoap management
|
||||
|
||||
** External Access
|
||||
- *Port 8000*: Icecast2 streaming and admin interface
|
||||
- *Port 1234*: Liquidsoap telnet control interface
|
||||
- All services bind to =0.0.0.0= for external access
|
||||
|
||||
** WSL Compatibility
|
||||
#+BEGIN_SRC bash
|
||||
# Find WSL IP for external access
|
||||
ip addr show eth0 | grep inet
|
||||
|
||||
# Access from Windows host
|
||||
# http://[IP-ADDRESS]:8000/asteroid.mp3 # 128kbps MP3
|
||||
# http://[IP-ADDRESS]:8000/asteroid.aac # 96kbps AAC
|
||||
# http://[IP-ADDRESS]:8000/asteroid-low.mp3 # 64kbps MP3
|
||||
#+END_SRC
|
||||
|
||||
* Production Deployment
|
||||
|
||||
** Docker Swarm Setup
|
||||
#+BEGIN_SRC yaml
|
||||
# docker compose.prod.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
icecast:
|
||||
image: moul/icecast
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
# ... rest of configuration
|
||||
|
||||
liquidsoap:
|
||||
image: savonet/liquidsoap:v2.2.x
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
# ... rest of configuration
|
||||
#+END_SRC
|
||||
|
||||
** Environment Variables
|
||||
#+BEGIN_SRC bash
|
||||
# Production environment
|
||||
export ASTEROID_ENV=production
|
||||
export ASTEROID_STREAM_QUALITY=high
|
||||
export ASTEROID_MAX_LISTENERS=200
|
||||
export ICECAST_ADMIN_PASSWORD=secure_password_here
|
||||
#+END_SRC
|
||||
|
||||
** SSL/TLS Setup
|
||||
Use reverse proxy (nginx/traefik) for HTTPS termination:
|
||||
#+BEGIN_SRC yaml
|
||||
# Add to docker-compose.yml
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/ssl:ro
|
||||
#+END_SRC
|
||||
|
||||
* Monitoring and Logging
|
||||
|
||||
** Container Health Checks
|
||||
#+BEGIN_SRC bash
|
||||
# Check container health
|
||||
docker compose exec icecast curl -f http://localhost:8000/status.xsl
|
||||
docker compose exec liquidsoap ps aux | grep liquidsoap
|
||||
|
||||
# Test telnet control interface
|
||||
echo "help" | nc localhost 1234
|
||||
#+END_SRC
|
||||
|
||||
** Log Management
|
||||
#+BEGIN_SRC bash
|
||||
# View real-time logs
|
||||
docker compose logs -f
|
||||
|
||||
# View specific service logs
|
||||
docker compose logs -f icecast
|
||||
docker compose logs -f liquidsoap
|
||||
|
||||
# Log rotation setup
|
||||
docker run --log-driver=json-file --log-opt max-size=10m --log-opt max-file=3
|
||||
#+END_SRC
|
||||
|
||||
* Troubleshooting
|
||||
|
||||
** Common Docker Issues
|
||||
|
||||
*** Container Won't Start
|
||||
#+BEGIN_SRC bash
|
||||
# Check container logs
|
||||
docker compose logs [service-name]
|
||||
|
||||
# Check resource usage
|
||||
docker stats
|
||||
|
||||
# Verify configuration files
|
||||
docker compose config
|
||||
#+END_SRC
|
||||
|
||||
*** Streaming Issues
|
||||
#+BEGIN_SRC bash
|
||||
# Test internal connectivity
|
||||
docker compose exec liquidsoap ping icecast
|
||||
|
||||
# Check Liquidsoap connection and logs
|
||||
docker compose logs liquidsoap
|
||||
|
||||
# Test telnet interface
|
||||
echo "request.queue" | nc localhost 1234
|
||||
#+END_SRC
|
||||
|
||||
*** Permission Issues
|
||||
#+BEGIN_SRC bash
|
||||
# Fix music directory permissions
|
||||
sudo chown -R $USER:$USER docker/music/
|
||||
chmod 755 docker/music/
|
||||
#+END_SRC
|
||||
|
||||
** Performance Tuning
|
||||
|
||||
*** Resource Limits
|
||||
#+BEGIN_SRC yaml
|
||||
# Add to services in docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
#+END_SRC
|
||||
|
||||
*** Network Optimization
|
||||
#+BEGIN_SRC yaml
|
||||
# Optimize network settings
|
||||
networks:
|
||||
asteroid-network:
|
||||
driver: bridge
|
||||
driver_opts:
|
||||
com.docker.network.driver.mtu: 1500
|
||||
#+END_SRC
|
||||
|
||||
This Docker streaming setup provides a complete containerized solution for Asteroid Radio with professional streaming capabilities and easy deployment.
|
||||
|
|
@ -0,0 +1,604 @@
|
|||
#+TITLE: Asteroid Radio - Installation Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Installation Overview
|
||||
|
||||
This guide covers the complete installation and deployment of Asteroid Radio. The **recommended approach** is Docker-based installation for easy deployment and consistency. Native installation is also available for development or custom deployments.
|
||||
|
||||
#+BEGIN_QUOTE
|
||||
*Note on Package Managers*: Examples in this guide use =apt= (Debian/Ubuntu). Replace with your distribution's package manager:
|
||||
- Fedora/RHEL: =dnf= or =yum=
|
||||
- Arch Linux: =pacman=
|
||||
- openSUSE: =zypper=
|
||||
- Alpine: =apk=
|
||||
#+END_QUOTE
|
||||
|
||||
* Quick Start (Docker - Recommended)
|
||||
|
||||
** Prerequisites Check
|
||||
#+BEGIN_SRC bash
|
||||
# Check if Docker is installed and running
|
||||
docker --version
|
||||
docker compose version
|
||||
docker info
|
||||
#+END_SRC
|
||||
|
||||
** One-Command Setup
|
||||
#+BEGIN_SRC bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/fade/asteroid.git asteroid-radio
|
||||
cd asteroid-radio/docker
|
||||
docker compose up -d
|
||||
#+END_SRC
|
||||
|
||||
** Verify Installation
|
||||
#+BEGIN_SRC bash
|
||||
# Check all three streams are working
|
||||
curl -I http://localhost:8000/asteroid.mp3 # 128kbps MP3
|
||||
curl -I http://localhost:8000/asteroid.aac # 96kbps AAC
|
||||
curl -I http://localhost:8000/asteroid-low.mp3 # 64kbps MP3
|
||||
#+END_SRC
|
||||
|
||||
* Detailed Installation
|
||||
|
||||
** System Requirements
|
||||
|
||||
*** Docker Installation Requirements
|
||||
- *OS*: Any OS with Docker support (Linux, macOS, Windows)
|
||||
- *Docker*: Docker Engine 20.10+ and Docker Compose 2.0+
|
||||
- *RAM*: 2GB minimum, 4GB recommended
|
||||
- *Storage*: 20GB minimum, 500GB+ for music library
|
||||
- *CPU*: 2 cores minimum, 4+ cores recommended
|
||||
- *Network*: Stable internet connection for streaming
|
||||
|
||||
*** Native Installation Requirements (Advanced)
|
||||
- *OS*: Ubuntu 20.04+ / Debian 11+ (for native installation)
|
||||
- *RAM*: 1GB minimum, 2GB recommended
|
||||
- *Storage*: 10GB minimum, 100GB+ for music library
|
||||
- *CPU*: 1 core minimum, 2+ cores recommended
|
||||
- *Dependencies*: SBCL, Icecast2, Liquidsoap, TagLib
|
||||
|
||||
** Docker Installation (Recommended)
|
||||
|
||||
*** Step 1: Install Docker
|
||||
#+BEGIN_SRC bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install -y docker.io docker compose
|
||||
sudo usermod -a -G docker $USER
|
||||
# Log out and back in for group changes
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo dnf install -y docker docker compose
|
||||
sudo systemctl enable --now docker
|
||||
sudo usermod -a -G docker $USER
|
||||
|
||||
# macOS
|
||||
brew install docker docker compose
|
||||
# Or install Docker Desktop
|
||||
|
||||
# Windows
|
||||
# Install Docker Desktop from docker.com
|
||||
#+END_SRC
|
||||
|
||||
*** Step 2: Clone and Setup
|
||||
#+BEGIN_SRC bash
|
||||
# Clone repository
|
||||
git clone <repository-url> asteroid-radio
|
||||
cd asteroid-radio/docker
|
||||
|
||||
# Start services
|
||||
docker compose up -d
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
#+END_SRC
|
||||
|
||||
*** Step 3: Add Music
|
||||
#+BEGIN_SRC bash
|
||||
# Copy music files to the docker music directory
|
||||
cp ~/path/to/music/*.mp3 music/
|
||||
cp ~/path/to/music/*.flac music/
|
||||
|
||||
# Set proper permissions
|
||||
sudo chown -R $USER:$USER music/
|
||||
#+END_SRC
|
||||
|
||||
*** Step 4: Access Streams
|
||||
- **High Quality MP3**: http://localhost:8000/asteroid.mp3 (128kbps)
|
||||
- **High Quality AAC**: http://localhost:8000/asteroid.aac (96kbps)
|
||||
- **Low Quality MP3**: http://localhost:8000/asteroid-low.mp3 (64kbps)
|
||||
- **Icecast Admin**: http://localhost:8000/admin/ (admin/asteroid_admin_2024)
|
||||
- **Telnet Control**: =telnet localhost 1234=
|
||||
|
||||
** Native Installation (Advanced Users)
|
||||
|
||||
*** Step 1: System Updates
|
||||
#+BEGIN_SRC bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
#+END_SRC
|
||||
|
||||
*** Step 2: Install System Dependencies
|
||||
#+BEGIN_SRC bash
|
||||
# Core dependencies
|
||||
sudo apt install -y sbcl git curl wget build-essential
|
||||
|
||||
# Streaming dependencies
|
||||
sudo apt install -y icecast2 liquidsoap
|
||||
|
||||
# Audio processing dependencies
|
||||
sudo apt install -y libtag1-dev libtagc0-dev
|
||||
|
||||
# Optional: Development tools
|
||||
sudo apt install -y emacs vim htop tree
|
||||
#+END_SRC
|
||||
|
||||
*** Step 3: Configure Icecast2
|
||||
#+BEGIN_SRC bash
|
||||
# Configure Icecast2 during installation
|
||||
sudo dpkg-reconfigure icecast2
|
||||
|
||||
# Or manually edit configuration
|
||||
sudo nano /etc/icecast2/icecast.xml
|
||||
#+END_SRC
|
||||
|
||||
*Icecast2 Configuration*:
|
||||
#+BEGIN_SRC xml
|
||||
<icecast>
|
||||
<location>Asteroid Radio Station</location>
|
||||
<admin>admin@asteroid-radio.local</admin>
|
||||
|
||||
<limits>
|
||||
<clients>100</clients>
|
||||
<sources>2</sources>
|
||||
<queue-size>524288</queue-size>
|
||||
<client-timeout>30</client-timeout>
|
||||
<header-timeout>15</header-timeout>
|
||||
<source-timeout>10</source-timeout>
|
||||
</limits>
|
||||
|
||||
<authentication>
|
||||
<source-password>b3l0wz3r0</source-password>
|
||||
<relay-password>asteroid_relay_2024</relay-password>
|
||||
<admin-user>admin</admin-user>
|
||||
<admin-password>asteroid_admin_2024</admin-password>
|
||||
</authentication>
|
||||
|
||||
<hostname>localhost</hostname>
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
</listen-socket>
|
||||
|
||||
<mount type="normal">
|
||||
<mount-name>/asteroid.mp3</mount-name>
|
||||
<username>source</username>
|
||||
<password>b3l0wz3r0</password>
|
||||
<max-listeners>50</max-listeners>
|
||||
<dump-file>/var/log/icecast2/asteroid.dump</dump-file>
|
||||
<burst-on-connect>1</burst-on-connect>
|
||||
<fallback-mount>/silence.mp3</fallback-mount>
|
||||
<fallback-override>1</fallback-override>
|
||||
</mount>
|
||||
|
||||
<fileserve>1</fileserve>
|
||||
<paths>
|
||||
<basedir>/usr/share/icecast2</basedir>
|
||||
<logdir>/var/log/icecast2</logdir>
|
||||
<webroot>/usr/share/icecast2/web</webroot>
|
||||
<adminroot>/usr/share/icecast2/admin</adminroot>
|
||||
<alias source="/" destination="/status.xsl"/>
|
||||
</paths>
|
||||
|
||||
<logging>
|
||||
<accesslog>access.log</accesslog>
|
||||
<errorlog>error.log</errorlog>
|
||||
<loglevel>3</loglevel>
|
||||
<logsize>10000</logsize>
|
||||
</logging>
|
||||
</icecast>
|
||||
#+END_SRC
|
||||
|
||||
*** Step 4: Install Quicklisp
|
||||
#+BEGIN_SRC bash
|
||||
# Download and install Quicklisp
|
||||
cd /tmp
|
||||
curl -O https://beta.quicklisp.org/quicklisp.lisp
|
||||
sbcl --load quicklisp.lisp --eval "(quicklisp-quickstart:install)" --eval "(ql:add-to-init-file)" --quit
|
||||
#+END_SRC
|
||||
|
||||
*** Step 5: Clone and Setup Project
|
||||
#+BEGIN_SRC bash
|
||||
# Clone repository
|
||||
git clone https://github.com/fade/asteroid /opt/asteroid-radio
|
||||
cd /opt/asteroid-radio
|
||||
|
||||
# Create required directories
|
||||
sudo mkdir -p music/incoming music/library static template
|
||||
sudo chown -R $USER:$USER music/
|
||||
|
||||
# Set permissions
|
||||
chmod 755 music/incoming music/library
|
||||
chmod +x *.sh
|
||||
#+END_SRC
|
||||
|
||||
*** Step 6: Install Lisp Dependencies
|
||||
#+BEGIN_SRC bash
|
||||
# Start SBCL and install dependencies
|
||||
sbcl --eval "(ql:quickload :asteroid)" --quit
|
||||
#+END_SRC
|
||||
|
||||
** CentOS/RHEL Installation
|
||||
|
||||
*** Step 1: Enable EPEL Repository
|
||||
#+BEGIN_SRC bash
|
||||
sudo dnf install -y epel-release
|
||||
sudo dnf update -y
|
||||
#+END_SRC
|
||||
|
||||
*** Step 2: Install Dependencies
|
||||
#+BEGIN_SRC bash
|
||||
# Core dependencies
|
||||
sudo dnf install -y sbcl git curl wget gcc make
|
||||
|
||||
# Streaming dependencies (may require additional repositories)
|
||||
sudo dnf install -y icecast liquidsoap
|
||||
|
||||
# Audio processing
|
||||
sudo dnf install -y taglib-devel
|
||||
#+END_SRC
|
||||
|
||||
*** Step 3: Follow Ubuntu Steps 3-6
|
||||
The remaining steps are similar to Ubuntu installation.
|
||||
|
||||
** macOS Installation (Development Only)
|
||||
|
||||
*** Step 1: Install Homebrew
|
||||
#+BEGIN_SRC bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
#+END_SRC
|
||||
|
||||
*** Step 2: Install Dependencies
|
||||
#+BEGIN_SRC bash
|
||||
# Core dependencies
|
||||
brew install sbcl git
|
||||
|
||||
# Streaming dependencies
|
||||
brew install icecast2 liquidsoap
|
||||
|
||||
# Audio processing
|
||||
brew install taglib
|
||||
#+END_SRC
|
||||
|
||||
*** Step 3: Follow Similar Setup Steps
|
||||
Adapt the Linux steps for macOS paths and conventions.
|
||||
|
||||
* Service Configuration
|
||||
|
||||
** Systemd Service Setup (Linux)
|
||||
|
||||
*** Icecast2 Service
|
||||
#+BEGIN_SRC bash
|
||||
# Enable and start Icecast2
|
||||
sudo systemctl enable icecast2
|
||||
sudo systemctl start icecast2
|
||||
sudo systemctl status icecast2
|
||||
#+END_SRC
|
||||
|
||||
*** Asteroid Radio Service
|
||||
Create systemd service file:
|
||||
#+BEGIN_SRC bash
|
||||
sudo nano /etc/systemd/system/asteroid-radio.service
|
||||
#+END_SRC
|
||||
|
||||
*Service Configuration*:
|
||||
#+BEGIN_SRC ini
|
||||
[Unit]
|
||||
Description=Asteroid Radio Streaming Service
|
||||
After=network.target icecast2.service
|
||||
Requires=icecast2.service
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=asteroid
|
||||
Group=asteroid
|
||||
WorkingDirectory=/opt/asteroid-radio
|
||||
ExecStart=/opt/asteroid-radio/start-asteroid-radio.sh
|
||||
ExecStop=/opt/asteroid-radio/stop-asteroid-radio.sh
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
#+END_SRC
|
||||
|
||||
*** Enable and Start Service
|
||||
#+BEGIN_SRC bash
|
||||
# Create service user
|
||||
sudo useradd -r -s /bin/false asteroid
|
||||
sudo chown -R asteroid:asteroid /opt/asteroid-radio
|
||||
|
||||
# Enable and start service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable asteroid-radio
|
||||
sudo systemctl start asteroid-radio
|
||||
sudo systemctl status asteroid-radio
|
||||
#+END_SRC
|
||||
|
||||
* Network Configuration
|
||||
|
||||
** Firewall Setup
|
||||
|
||||
*** Ubuntu/Debian (ufw)
|
||||
#+BEGIN_SRC bash
|
||||
# Allow required ports
|
||||
sudo ufw allow 8000/tcp # Icecast2 streaming and admin
|
||||
sudo ufw allow 1234/tcp # Liquidsoap telnet control (optional)
|
||||
sudo ufw enable
|
||||
#+END_SRC
|
||||
|
||||
*** CentOS/RHEL (firewalld)
|
||||
#+BEGIN_SRC bash
|
||||
# Allow required ports
|
||||
sudo firewall-cmd --permanent --add-port=8000/tcp # Icecast2
|
||||
sudo firewall-cmd --permanent --add-port=1234/tcp # Liquidsoap telnet (optional)
|
||||
sudo firewall-cmd --reload
|
||||
#+END_SRC
|
||||
|
||||
** Reverse Proxy Setup (Optional)
|
||||
|
||||
*** Nginx Configuration
|
||||
#+BEGIN_SRC bash
|
||||
# Install Nginx
|
||||
sudo apt install nginx
|
||||
|
||||
# Create configuration
|
||||
sudo nano /etc/nginx/sites-available/asteroid-radio
|
||||
#+END_SRC
|
||||
|
||||
*Nginx Configuration*:
|
||||
#+BEGIN_SRC nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# Web interface
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Streaming endpoint
|
||||
location /stream {
|
||||
proxy_pass http://localhost:8000/asteroid.mp3;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
*** Enable Nginx Site
|
||||
#+BEGIN_SRC bash
|
||||
sudo ln -s /etc/nginx/sites-available/asteroid-radio /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
#+END_SRC
|
||||
|
||||
* Docker Management
|
||||
|
||||
** Stream Services
|
||||
|
||||
The stream services can be managed using docker from inside the =docker= folder on this repository.
|
||||
|
||||
*** Container Management
|
||||
#+BEGIN_SRC bash
|
||||
# Start services
|
||||
docker compose up -d
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
#+END_SRC
|
||||
|
||||
*** Docker Configuration
|
||||
See =docker/docker-compose.yml= for complete Docker setup with Icecast2 and Liquidsoap containers. The setup includes:
|
||||
- **Icecast2**: Streaming server with three output formats
|
||||
- **Liquidsoap**: Audio processing and stream generation
|
||||
- **Music Volume**: Mounted to the =./music/library= directory (can also be set with the =MUSIC_LIBRARY= environment variable)
|
||||
- *Queue Playlist*: Mounted to the =./stream-queue.m3u= file (can also be set with the =QUEUE_PLAYLIST= environment variable)
|
||||
|
||||
** Asteroid Radio Application
|
||||
|
||||
The asteroid radio application can also be served and managed using docker from inside the =docker= folder on this repository.
|
||||
|
||||
*** Container Management
|
||||
#+BEGIN_SRC bash
|
||||
# Build service
|
||||
docker compose -f docker-compose.asteroid.yml build
|
||||
|
||||
# Start service
|
||||
docker compose -f docker-compose.asteroid.yml up -d
|
||||
|
||||
# Stop service
|
||||
docker compose -f docker-compose.asteroid.yml down
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.asteroid.yml logs -f
|
||||
|
||||
# Restart service
|
||||
docker compose -f docker-compose.asteroid.yml restart
|
||||
#+END_SRC
|
||||
|
||||
*** Docker Configuration
|
||||
See =docker/docker-compose.asteroid.yml= for complete Docker setup, which includes:
|
||||
- Buils the application using the current cloned branch for the repository
|
||||
- Uses the host network for easy access to the stream endpoint
|
||||
- *Stream endpoint* mapped to =http://localhost:8000= (can also be set with the =ASTEROID_STREAM_URL= environment variable)
|
||||
- **Music Volume**: Mounted to the =./music/library= directory (can also be set with the =MUSIC_LIBRARY= environment variable)
|
||||
- *Queue Playlist*: Mounted to the =./stream-queue.m3u= file (can also be set with the =QUEUE_PLAYLIST= environment variable)
|
||||
|
||||
* Initial Configuration
|
||||
|
||||
** First-Time Setup
|
||||
|
||||
*** Access Streaming Services
|
||||
1. **Icecast Admin**: http://localhost:8000/admin/ (admin/asteroid_admin_2024)
|
||||
2. **Stream URLs**:
|
||||
- High Quality MP3: http://localhost:8000/asteroid.mp3 (128kbps)
|
||||
- High Quality AAC: http://localhost:8000/asteroid.aac (96kbps)
|
||||
- Low Quality MP3: http://localhost:8000/asteroid-low.mp3 (64kbps)
|
||||
3. **Telnet Control**: =telnet localhost 1234= (for Liquidsoap management)
|
||||
|
||||
*** Add Music Library
|
||||
#+BEGIN_SRC bash
|
||||
# Copy music files to music directory
|
||||
cp ~/path/to/music/*.mp3 ~/asteroid-radio/music/
|
||||
|
||||
# Files are automatically detected by Liquidsoap
|
||||
# No additional processing needed - just add files to the music directory
|
||||
#+END_SRC
|
||||
|
||||
*** Test Streaming
|
||||
#+BEGIN_SRC bash
|
||||
# Test all streams with curl
|
||||
curl -I http://localhost:8000/asteroid.mp3 # 128kbps MP3
|
||||
curl -I http://localhost:8000/asteroid.aac # 96kbps AAC
|
||||
curl -I http://localhost:8000/asteroid-low.mp3 # 64kbps MP3
|
||||
|
||||
# Test with media player
|
||||
vlc http://localhost:8000/asteroid.mp3 # High quality MP3
|
||||
vlc http://localhost:8000/asteroid.aac # High quality AAC
|
||||
#+END_SRC
|
||||
|
||||
** Configuration Files
|
||||
|
||||
*** Key Configuration Locations
|
||||
*Docker Setup:*
|
||||
- =docker/asteroid-radio-docker.liq= - Liquidsoap streaming configuration
|
||||
- =docker/icecast.xml= - Icecast2 server settings
|
||||
- =docker/docker-compose.yml= - Container orchestration
|
||||
|
||||
*Native Setup:*
|
||||
- =asteroid-radio.liq= - Liquidsoap streaming configuration
|
||||
- =/etc/icecast2/icecast.xml= - Icecast2 server settings
|
||||
- =radiance-core.conf.lisp= - RADIANCE framework configuration
|
||||
|
||||
|
||||
* Production Deployment
|
||||
|
||||
** Security Considerations
|
||||
|
||||
*** Change Default Passwords
|
||||
- Update Icecast2 admin password
|
||||
- Change streaming source password
|
||||
- Secure database access if using external DB
|
||||
|
||||
*** File Permissions
|
||||
#+BEGIN_SRC bash
|
||||
# Secure file permissions
|
||||
sudo chown -R asteroid:asteroid /opt/asteroid-radio
|
||||
sudo chmod 750 /opt/asteroid-radio
|
||||
sudo chmod 640 /opt/asteroid-radio/config/*
|
||||
#+END_SRC
|
||||
|
||||
*** Network Security
|
||||
- Use HTTPS with SSL certificates
|
||||
- Implement rate limiting
|
||||
- Configure fail2ban for brute force protection
|
||||
|
||||
** Performance Tuning
|
||||
|
||||
*** System Limits
|
||||
#+BEGIN_SRC bash
|
||||
# Increase file descriptor limits
|
||||
echo "asteroid soft nofile 65536" | sudo tee -a /etc/security/limits.conf
|
||||
echo "asteroid hard nofile 65536" | sudo tee -a /etc/security/limits.conf
|
||||
#+END_SRC
|
||||
|
||||
*** Icecast2 Optimization
|
||||
- Adjust client limits based on server capacity
|
||||
- Configure appropriate buffer sizes
|
||||
- Enable burst-on-connect for better user experience
|
||||
|
||||
** Monitoring Setup
|
||||
|
||||
*** Log Monitoring
|
||||
#+BEGIN_SRC bash
|
||||
# Docker setup - monitor container logs
|
||||
docker compose logs -f icecast
|
||||
docker compose logs -f liquidsoap
|
||||
|
||||
# Native setup - monitor system logs
|
||||
sudo tail -f /var/log/icecast2/error.log
|
||||
sudo tail -f /var/log/asteroid-radio/asteroid.log
|
||||
#+END_SRC
|
||||
|
||||
*** Health Checks
|
||||
#+BEGIN_SRC bash
|
||||
# Create health check script
|
||||
cat > ~/asteroid-radio/health-check.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Check all three streams
|
||||
curl -I http://localhost:8000/asteroid.mp3 | grep -q "200 OK" || exit 1
|
||||
curl -I http://localhost:8000/asteroid.aac | grep -q "200 OK" || exit 1
|
||||
curl -I http://localhost:8000/asteroid-low.mp3 | grep -q "200 OK" || exit 1
|
||||
# Check Icecast admin interface
|
||||
curl -f http://localhost:8000/admin/ || exit 1
|
||||
EOF
|
||||
chmod +x ~/asteroid-radio/health-check.sh
|
||||
#+END_SRC
|
||||
|
||||
* Troubleshooting
|
||||
|
||||
** Common Installation Issues
|
||||
|
||||
*** Dependency Problems
|
||||
- Ensure all system packages are installed
|
||||
- Check Quicklisp installation
|
||||
- Verify SBCL can load all required libraries
|
||||
|
||||
*** Permission Issues
|
||||
- Check file ownership and permissions
|
||||
- Verify service user has access to required directories
|
||||
- Ensure music directories are writable
|
||||
|
||||
*** Network Issues
|
||||
- Confirm firewall allows required ports
|
||||
- Check service binding addresses
|
||||
- Verify no port conflicts with other services
|
||||
|
||||
*** Streaming Issues
|
||||
- Check Icecast2 configuration and logs
|
||||
- Verify Liquidsoap can access music files
|
||||
- Test stream connectivity from different networks
|
||||
|
||||
** Getting Support
|
||||
- Check project documentation
|
||||
- Review system logs for error messages
|
||||
- Submit issues with detailed system information
|
||||
- Join our IRC chat room: **#asteroid.music** on **irc.libera.chat**
|
||||
- Join community discussions for help
|
||||
|
||||
* Maintenance
|
||||
|
||||
** Regular Maintenance Tasks
|
||||
- Update system packages monthly
|
||||
- Monitor disk space for music library
|
||||
- Review and rotate log files
|
||||
- Backup configuration files
|
||||
- Test streaming functionality
|
||||
|
||||
** Updates and Upgrades
|
||||
- Follow project release notes
|
||||
- Test updates in development environment first
|
||||
- Backup before major upgrades
|
||||
- Monitor service status after updates
|
||||
|
||||
This installation guide provides comprehensive setup instructions for Asteroid Radio. For development-specific setup, see the Development Guide.
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
#+TITLE: Playlist System - Complete (MVP)
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
Implemented user playlist system with creation, storage, and playback functionality. Core features complete with database update limitations noted for PostgreSQL migration.
|
||||
|
||||
* What Was Completed
|
||||
|
||||
** Playlist Creation
|
||||
- Create empty playlists with name and description
|
||||
- Save queue as playlist (captures current queue state)
|
||||
- User-specific playlists (tied to user ID)
|
||||
- Automatic timestamp tracking
|
||||
|
||||
** Playlist Management
|
||||
- View all user playlists
|
||||
- Display playlist metadata (name, track count, date)
|
||||
- Load playlists into play queue
|
||||
- Automatic playback on load
|
||||
|
||||
** Playlist Playback
|
||||
- Load playlist tracks into queue
|
||||
- Start playing first track automatically
|
||||
- Queue displays remaining tracks
|
||||
- Full playback controls available
|
||||
|
||||
* Features Implemented
|
||||
|
||||
** User Interface
|
||||
|
||||
*** Playlist Creation Form
|
||||
#+BEGIN_SRC html
|
||||
<div class="playlist-controls">
|
||||
<input type="text" id="new-playlist-name" placeholder="New playlist name...">
|
||||
<button id="create-playlist">➕ Create Playlist</button>
|
||||
</div>
|
||||
#+END_SRC
|
||||
|
||||
*** Playlist Display
|
||||
- Shows all user playlists
|
||||
- Displays track count
|
||||
- Load button for each playlist
|
||||
- Clean card-based layout
|
||||
|
||||
*** Queue Integration
|
||||
- "Save as Playlist" button in queue
|
||||
- Prompts for playlist name
|
||||
- Saves all queued tracks
|
||||
- Immediate feedback
|
||||
|
||||
** API Endpoints
|
||||
|
||||
*** GET /api/playlists
|
||||
Get all playlists for current user
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"playlists": [
|
||||
{
|
||||
"id": 12,
|
||||
"name": "My Favorites",
|
||||
"description": "Created from queue with 3 tracks",
|
||||
"track-count": 3,
|
||||
"created-date": 1759559112
|
||||
}
|
||||
]
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
*** POST /api/playlists/create
|
||||
Create a new playlist
|
||||
#+BEGIN_SRC
|
||||
POST /asteroid/api/playlists/create
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
name=My Playlist&description=Optional description
|
||||
#+END_SRC
|
||||
|
||||
*** GET /api/playlists/:id
|
||||
Get playlist details with tracks
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"playlist": {
|
||||
"id": 12,
|
||||
"name": "My Favorites",
|
||||
"tracks": [
|
||||
{
|
||||
"id": 1298,
|
||||
"title": ["City Lights From A Train"],
|
||||
"artist": ["Vector Lovers"],
|
||||
"album": ["Capsule For One"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
*** POST /api/playlists/add-track
|
||||
Add track to playlist (limited by database backend)
|
||||
#+BEGIN_SRC
|
||||
POST /asteroid/api/playlists/add-track
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
playlist-id=12&track-id=1298
|
||||
#+END_SRC
|
||||
|
||||
* Technical Implementation
|
||||
|
||||
** Database Schema
|
||||
|
||||
*** Playlists Collection
|
||||
#+BEGIN_SRC lisp
|
||||
(db:create "playlists"
|
||||
'((name :text)
|
||||
(description :text)
|
||||
(user-id :integer)
|
||||
(tracks :text) ; List of track IDs
|
||||
(created-date :integer)
|
||||
(modified-date :integer)))
|
||||
#+END_SRC
|
||||
|
||||
** Backend Functions (playlist-management.lisp)
|
||||
|
||||
*** Create Playlist
|
||||
#+BEGIN_SRC lisp
|
||||
(defun create-playlist (user-id name &optional description)
|
||||
"Create a new playlist for a user"
|
||||
(let ((playlist-data `(("user-id" ,user-id)
|
||||
("name" ,name)
|
||||
("description" ,(or description ""))
|
||||
("tracks" ())
|
||||
("created-date" ,(local-time:timestamp-to-unix (local-time:now)))
|
||||
("modified-date" ,(local-time:timestamp-to-unix (local-time:now))))))
|
||||
(db:insert "playlists" playlist-data)
|
||||
t))
|
||||
#+END_SRC
|
||||
|
||||
*** Get User Playlists
|
||||
#+BEGIN_SRC lisp
|
||||
(defun get-user-playlists (user-id)
|
||||
"Get all playlists for a user"
|
||||
;; Manual filtering due to database ID type mismatch
|
||||
(let ((all-playlists (db:select "playlists" (db:query :all))))
|
||||
(remove-if-not (lambda (playlist)
|
||||
(let ((stored-user-id (gethash "user-id" playlist)))
|
||||
(or (equal stored-user-id user-id)
|
||||
(and (listp stored-user-id)
|
||||
(equal (first stored-user-id) user-id)))))
|
||||
all-playlists)))
|
||||
#+END_SRC
|
||||
|
||||
*** Get Playlist by ID
|
||||
#+BEGIN_SRC lisp
|
||||
(defun get-playlist-by-id (playlist-id)
|
||||
"Get a specific playlist by ID"
|
||||
;; Manual search to handle ID type variations
|
||||
(let ((all-playlists (db:select "playlists" (db:query :all))))
|
||||
(find-if (lambda (playlist)
|
||||
(let ((stored-id (gethash "_id" playlist)))
|
||||
(or (equal stored-id playlist-id)
|
||||
(and (listp stored-id)
|
||||
(equal (first stored-id) playlist-id)))))
|
||||
all-playlists)))
|
||||
#+END_SRC
|
||||
|
||||
** Frontend Implementation
|
||||
|
||||
*** Save Queue as Playlist
|
||||
#+BEGIN_SRC javascript
|
||||
async function saveQueueAsPlaylist() {
|
||||
const name = prompt('Enter playlist name:');
|
||||
if (!name) return;
|
||||
|
||||
// Create playlist
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('description', `Created from queue with ${playQueue.length} tracks`);
|
||||
|
||||
const response = await fetch('/asteroid/api/playlists/create', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
// Add tracks to playlist
|
||||
for (const track of playQueue) {
|
||||
const addFormData = new FormData();
|
||||
addFormData.append('playlist-id', newPlaylist.id);
|
||||
addFormData.append('track-id', track.id);
|
||||
|
||||
await fetch('/asteroid/api/playlists/add-track', {
|
||||
method: 'POST',
|
||||
body: addFormData
|
||||
});
|
||||
}
|
||||
|
||||
alert(`Playlist "${name}" created with ${playQueue.length} tracks!`);
|
||||
loadPlaylists();
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
*** Load Playlist
|
||||
#+BEGIN_SRC javascript
|
||||
async function loadPlaylist(playlistId) {
|
||||
const response = await fetch(`/asteroid/api/playlists/${playlistId}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success' && result.playlist) {
|
||||
const playlist = result.playlist;
|
||||
|
||||
// Clear current queue
|
||||
playQueue = [];
|
||||
|
||||
// Add all playlist tracks to queue
|
||||
playlist.tracks.forEach(track => {
|
||||
const fullTrack = tracks.find(t => t.id === track.id);
|
||||
if (fullTrack) {
|
||||
playQueue.push(fullTrack);
|
||||
}
|
||||
});
|
||||
|
||||
updateQueueDisplay();
|
||||
|
||||
// Start playing first track
|
||||
if (playQueue.length > 0) {
|
||||
const firstTrack = playQueue.shift();
|
||||
const trackIndex = tracks.findIndex(t => t.id === firstTrack.id);
|
||||
if (trackIndex >= 0) {
|
||||
playTrack(trackIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
* Known Limitations (Requires PostgreSQL)
|
||||
|
||||
** Database Update Issues
|
||||
The current Radiance database backend has limitations:
|
||||
|
||||
*** Problem: Updates Don't Persist
|
||||
#+BEGIN_SRC lisp
|
||||
;; This doesn't work reliably with current backend
|
||||
(db:update "playlists"
|
||||
(db:query (:= "_id" playlist-id))
|
||||
`(("tracks" ,new-tracks)))
|
||||
#+END_SRC
|
||||
|
||||
*** Impact
|
||||
- Cannot add tracks to existing playlists after creation
|
||||
- Cannot modify playlist metadata after creation
|
||||
- Workaround: Create playlist with all tracks at once (save queue as playlist)
|
||||
|
||||
*** Solution
|
||||
Migration to PostgreSQL will resolve this:
|
||||
- Proper UPDATE query support
|
||||
- Consistent data types
|
||||
- Better query matching
|
||||
- Full CRUD operations
|
||||
|
||||
** Type Handling Issues
|
||||
Database stores some values as lists when they should be scalars:
|
||||
- =user-id= stored as =(2)= instead of =2=
|
||||
- =_id= sometimes wrapped in list
|
||||
- Requires manual type checking in queries
|
||||
|
||||
*** Current Workaround
|
||||
#+BEGIN_SRC lisp
|
||||
;; Handle both scalar and list values
|
||||
(let ((stored-id (gethash "_id" playlist)))
|
||||
(or (equal stored-id playlist-id)
|
||||
(and (listp stored-id)
|
||||
(equal (first stored-id) playlist-id))))
|
||||
#+END_SRC
|
||||
|
||||
* Working Features (MVP)
|
||||
|
||||
** ✅ Core Workflow
|
||||
1. User adds tracks to queue
|
||||
2. User saves queue as playlist
|
||||
3. Playlist created with all tracks
|
||||
4. User can view playlists
|
||||
5. User can load and play playlists
|
||||
|
||||
** ✅ Tested Scenarios
|
||||
- Create empty playlist ✅
|
||||
- Save 3-track queue as playlist ✅
|
||||
- Load playlist into queue ✅
|
||||
- Play playlist tracks ✅
|
||||
- Multiple playlists per user ✅
|
||||
- Playlist persistence across sessions ✅
|
||||
|
||||
* Files Created/Modified
|
||||
|
||||
** New Files
|
||||
- =playlist-management.lisp= - Core playlist functions
|
||||
- =docs/PLAYLIST-SYSTEM.org= - This documentation
|
||||
|
||||
** Modified Files
|
||||
- =asteroid.asd= - Added playlist-management.lisp
|
||||
- =asteroid.lisp= - Added playlist API endpoints
|
||||
- =template/player.chtml= - Added playlist UI and functions
|
||||
- =database.lisp= - Playlists collection schema
|
||||
|
||||
* Future Enhancements (Post-PostgreSQL)
|
||||
|
||||
** Playlist Editing
|
||||
- Add tracks to existing playlists
|
||||
- Remove tracks from playlists
|
||||
- Reorder tracks
|
||||
- Update playlist metadata
|
||||
|
||||
** Advanced Features
|
||||
- Playlist sharing
|
||||
- Collaborative playlists
|
||||
- Playlist import/export
|
||||
- Smart playlists (auto-generated)
|
||||
- Playlist statistics
|
||||
|
||||
** Liquidsoap Integration
|
||||
- Stream user playlists
|
||||
- Scheduled playlist playback
|
||||
- Multiple mount points per user
|
||||
- Real-time playlist updates
|
||||
|
||||
* Status: ⚠️ PARTIAL - Core Features Working, Playlist Playback Limited
|
||||
|
||||
Core functionality working. Users can browse and play tracks from library. Audio playback functional after adding get-track-by-id function with type mismatch handling. Playlist system has significant limitations due to database backend issues.
|
||||
|
||||
** What Works Now
|
||||
- ✅ Browse track library (with pagination)
|
||||
- ✅ Play tracks from library
|
||||
- ✅ Add tracks to queue
|
||||
- ✅ Audio playback (fixed: added get-track-by-id with manual search)
|
||||
- ✅ Create empty playlists
|
||||
- ✅ View playlists
|
||||
|
||||
** What Doesn't Work (Database Limitations)
|
||||
- ❌ Save queue as playlist (tracks don't persist - database update fails)
|
||||
- ❌ Load playlists (playlists are empty - no tracks saved)
|
||||
- ❌ Playlist playback (no tracks in playlists to play)
|
||||
- ❌ Add tracks to existing playlists (database update limitation)
|
||||
- ❌ Edit playlist metadata (database update limitation)
|
||||
- ❌ Remove tracks from playlists (database update limitation)
|
||||
|
||||
** Root Cause
|
||||
The Radiance default database backend has critical limitations:
|
||||
1. =db:update= queries don't persist changes
|
||||
2. Type mismatches (IDs stored as lists vs scalars)
|
||||
3. Query matching failures
|
||||
|
||||
** Workaround
|
||||
None available with current database backend. Full playlist functionality requires PostgreSQL migration.
|
||||
|
||||
** Recent Fix (2025-10-04)
|
||||
Added missing =get-track-by-id= function to enable audio streaming:
|
||||
#+BEGIN_SRC lisp
|
||||
(defun get-track-by-id (track-id)
|
||||
"Get a track by its ID"
|
||||
(let ((tracks (db:select "tracks" (db:query (:= "_id" track-id)))))
|
||||
(when (> (length tracks) 0)
|
||||
(first tracks))))
|
||||
#+END_SRC
|
||||
|
||||
This function is required by the =/tracks/:id/stream= endpoint for audio playback.
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
#+TITLE: PostgreSQL Setup for Asteroid Radio
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
Complete PostgreSQL setup with Docker, persistent storage, and Radiance integration for Asteroid Radio.
|
||||
|
||||
* What This Provides
|
||||
|
||||
** Persistent Storage
|
||||
- All data survives container restarts
|
||||
- Database stored in Docker volume =postgres-data=
|
||||
- Automatic backups possible
|
||||
|
||||
** Full Database Features
|
||||
- Proper UPDATE/DELETE operations
|
||||
- Transactions and ACID compliance
|
||||
- Indexes for fast queries
|
||||
- Foreign key constraints
|
||||
- Triggers for automatic timestamps
|
||||
|
||||
** Tables Created
|
||||
- =users= - User accounts with roles
|
||||
- =tracks= - Music library metadata
|
||||
- =playlists= - User playlists
|
||||
- =playlist_tracks= - Many-to-many playlist/track relationship
|
||||
- =sessions= - Session management
|
||||
|
||||
* Quick Start
|
||||
|
||||
** 1. Start PostgreSQL Container
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
cd docker
|
||||
docker compose up -d postgres
|
||||
#+END_SRC
|
||||
|
||||
Wait 10 seconds for initialization, then verify:
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
docker logs asteroid-postgres
|
||||
#+END_SRC
|
||||
|
||||
You should see: "Asteroid Radio database initialized successfully!"
|
||||
|
||||
** 2. Test Connection
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
docker exec -it asteroid-postgres psql -U asteroid -d asteroid
|
||||
#+END_SRC
|
||||
|
||||
Inside psql:
|
||||
#+BEGIN_SRC sql
|
||||
\dt -- List tables
|
||||
SELECT * FROM users; -- View users
|
||||
\q -- Quit
|
||||
#+END_SRC
|
||||
|
||||
** 3. Configure Radiance (When Ready)
|
||||
|
||||
Edit your Radiance configuration to use PostgreSQL:
|
||||
|
||||
#+BEGIN_SRC lisp
|
||||
(load "config/radiance-postgres.lisp")
|
||||
#+END_SRC
|
||||
|
||||
* Database Schema
|
||||
|
||||
** Users Table
|
||||
#+BEGIN_SRC sql
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'listener',
|
||||
active BOOLEAN DEFAULT true,
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP
|
||||
);
|
||||
#+END_SRC
|
||||
|
||||
** Tracks Table
|
||||
#+BEGIN_SRC sql
|
||||
CREATE TABLE tracks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
artist VARCHAR(500),
|
||||
album VARCHAR(500),
|
||||
duration INTEGER DEFAULT 0,
|
||||
format VARCHAR(50),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
play_count INTEGER DEFAULT 0,
|
||||
added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_played TIMESTAMP
|
||||
);
|
||||
#+END_SRC
|
||||
|
||||
** Playlists Table
|
||||
#+BEGIN_SRC sql
|
||||
CREATE TABLE playlists (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
#+END_SRC
|
||||
|
||||
** Playlist Tracks Junction Table
|
||||
#+BEGIN_SRC sql
|
||||
CREATE TABLE playlist_tracks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(playlist_id, track_id, position)
|
||||
);
|
||||
#+END_SRC
|
||||
|
||||
* Connection Details
|
||||
|
||||
** From Host Machine
|
||||
- Host: =localhost=
|
||||
- Port: =5432=
|
||||
- Database: =asteroid=
|
||||
- Username: =asteroid=
|
||||
- Password: =asteroid_db_2025=
|
||||
|
||||
** From Docker Containers
|
||||
- Host: =asteroid-postgres=
|
||||
- Port: =5432=
|
||||
- Database: =asteroid=
|
||||
- Username: =asteroid=
|
||||
- Password: =asteroid_db_2025=
|
||||
|
||||
** Connection String
|
||||
#+BEGIN_SRC
|
||||
postgresql://asteroid:asteroid_db_2025@localhost:5432/asteroid
|
||||
#+END_SRC
|
||||
|
||||
* Default Users
|
||||
|
||||
** Admin User
|
||||
- Username: =admin=
|
||||
- Password: =admin= (⚠️ CHANGE THIS!)
|
||||
- Role: =admin=
|
||||
|
||||
** Test Listener
|
||||
- Username: =listener=
|
||||
- Password: =admin= (⚠️ CHANGE THIS!)
|
||||
- Role: =listener=
|
||||
|
||||
* Management Commands
|
||||
|
||||
** Access PostgreSQL CLI
|
||||
#+BEGIN_SRC bash
|
||||
docker exec -it asteroid-postgres psql -U asteroid -d asteroid
|
||||
#+END_SRC
|
||||
|
||||
** View All Tables
|
||||
#+BEGIN_SRC sql
|
||||
\dt
|
||||
#+END_SRC
|
||||
|
||||
** View Table Structure
|
||||
#+BEGIN_SRC sql
|
||||
\d users
|
||||
\d tracks
|
||||
\d playlists
|
||||
\d playlist_tracks
|
||||
#+END_SRC
|
||||
|
||||
** Count Records
|
||||
#+BEGIN_SRC sql
|
||||
SELECT COUNT(*) FROM users;
|
||||
SELECT COUNT(*) FROM tracks;
|
||||
SELECT COUNT(*) FROM playlists;
|
||||
#+END_SRC
|
||||
|
||||
** View Playlists with Track Counts
|
||||
#+BEGIN_SRC sql
|
||||
SELECT p.id, p.name, u.username, COUNT(pt.track_id) as track_count
|
||||
FROM playlists p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
LEFT JOIN playlist_tracks pt ON p.id = pt.playlist_id
|
||||
GROUP BY p.id, p.name, u.username;
|
||||
#+END_SRC
|
||||
|
||||
* Backup and Restore
|
||||
|
||||
** Create Backup
|
||||
#+BEGIN_SRC bash
|
||||
docker exec asteroid-postgres pg_dump -U asteroid asteroid > backup.sql
|
||||
#+END_SRC
|
||||
|
||||
** Restore from Backup
|
||||
#+BEGIN_SRC bash
|
||||
cat backup.sql | docker exec -i asteroid-postgres psql -U asteroid -d asteroid
|
||||
#+END_SRC
|
||||
|
||||
** Backup with Docker Volume
|
||||
#+BEGIN_SRC bash
|
||||
docker run --rm \
|
||||
-v docker_postgres-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar czf /backup/postgres-backup.tar.gz /data
|
||||
#+END_SRC
|
||||
|
||||
* Migration from Radiance Default DB
|
||||
|
||||
** Export Current Data
|
||||
|
||||
Create a script to export from current database:
|
||||
|
||||
#+BEGIN_SRC lisp
|
||||
(defun export-users-to-postgres ()
|
||||
"Export users from Radiance DB to PostgreSQL"
|
||||
(let ((users (db:select "users" (db:query :all))))
|
||||
(loop for user in users
|
||||
do (format t "INSERT INTO users (username, email, password_hash, role, active) VALUES (~
|
||||
'~a', '~a', '~a', '~a', ~a);~%"
|
||||
(gethash "username" user)
|
||||
(gethash "email" user)
|
||||
(gethash "password-hash" user)
|
||||
(gethash "role" user)
|
||||
(gethash "active" user)))))
|
||||
#+END_SRC
|
||||
|
||||
** Import to PostgreSQL
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# Run export script, save to file
|
||||
# Then import:
|
||||
cat export.sql | docker exec -i asteroid-postgres psql -U asteroid -d asteroid
|
||||
#+END_SRC
|
||||
|
||||
* Troubleshooting
|
||||
|
||||
** Container Won't Start
|
||||
|
||||
Check logs:
|
||||
#+BEGIN_SRC bash
|
||||
docker logs asteroid-postgres
|
||||
#+END_SRC
|
||||
|
||||
** Connection Refused
|
||||
|
||||
Ensure container is running:
|
||||
#+BEGIN_SRC bash
|
||||
docker ps | grep postgres
|
||||
#+END_SRC
|
||||
|
||||
Check health:
|
||||
#+BEGIN_SRC bash
|
||||
docker exec asteroid-postgres pg_isready -U asteroid
|
||||
#+END_SRC
|
||||
|
||||
** Permission Denied
|
||||
|
||||
Reset permissions:
|
||||
#+BEGIN_SRC bash
|
||||
docker exec -it asteroid-postgres psql -U postgres -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO asteroid;"
|
||||
#+END_SRC
|
||||
|
||||
** Data Not Persisting
|
||||
|
||||
Check volume:
|
||||
#+BEGIN_SRC bash
|
||||
docker volume ls | grep postgres
|
||||
docker volume inspect docker_postgres-data
|
||||
#+END_SRC
|
||||
|
||||
* Performance Tuning
|
||||
|
||||
** Increase Shared Buffers
|
||||
|
||||
Edit docker-compose.yml:
|
||||
#+BEGIN_SRC yaml
|
||||
postgres:
|
||||
command: postgres -c shared_buffers=256MB -c max_connections=100
|
||||
#+END_SRC
|
||||
|
||||
** Enable Query Logging
|
||||
|
||||
#+BEGIN_SRC yaml
|
||||
postgres:
|
||||
command: postgres -c log_statement=all
|
||||
#+END_SRC
|
||||
|
||||
* Security Recommendations
|
||||
|
||||
** Change Default Passwords
|
||||
|
||||
#+BEGIN_SRC sql
|
||||
ALTER USER asteroid WITH PASSWORD 'new_secure_password';
|
||||
UPDATE users SET password_hash = '$2a$12$...' WHERE username = 'admin';
|
||||
#+END_SRC
|
||||
|
||||
** Restrict Network Access
|
||||
|
||||
In production, don't expose port 5432 externally:
|
||||
#+BEGIN_SRC yaml
|
||||
postgres:
|
||||
ports: [] # Remove port mapping
|
||||
#+END_SRC
|
||||
|
||||
** Enable SSL
|
||||
|
||||
Add to docker-compose.yml:
|
||||
#+BEGIN_SRC yaml
|
||||
postgres:
|
||||
command: postgres -c ssl=on -c ssl_cert_file=/etc/ssl/certs/server.crt
|
||||
#+END_SRC
|
||||
|
||||
* Next Steps
|
||||
|
||||
1. ✅ PostgreSQL container running
|
||||
2. ⏳ Configure Radiance to use PostgreSQL
|
||||
3. ⏳ Migrate existing data
|
||||
4. ⏳ Update application code for PostgreSQL
|
||||
5. ⏳ Test playlist functionality
|
||||
6. ⏳ Deploy to production
|
||||
|
||||
* Status: ✅ READY FOR INTEGRATION
|
||||
|
||||
PostgreSQL is set up and ready. Next step is configuring Radiance and migrating data.
|
||||
|
||||
** What Works Now
|
||||
- ✅ PostgreSQL container running
|
||||
- ✅ Database initialized with schema
|
||||
- ✅ Persistent storage configured
|
||||
- ✅ Default users created
|
||||
- ✅ Indexes and constraints in place
|
||||
|
||||
** What Needs Fade
|
||||
- ⏳ Radiance PostgreSQL adapter configuration
|
||||
- ⏳ Data migration from current DB
|
||||
- ⏳ Application code updates
|
||||
- ⏳ Testing and validation
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
#+TITLE: Asteroid Radio - Project Development History
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
#+DESCRIPTION: Comprehensive history of the Asteroid Radio project from inception to present
|
||||
|
||||
* Project Overview
|
||||
|
||||
Asteroid Radio is a web-based internet radio station built with Common Lisp, featuring a hacker-themed terminal aesthetic. The project combines the Radiance web framework with Icecast/Liquidsoap streaming infrastructure to create a full-featured music streaming platform.
|
||||
|
||||
** Technology Stack
|
||||
- *Backend*: Common Lisp (SBCL), Radiance web framework
|
||||
- *Streaming*: Icecast2, Liquidsoap
|
||||
- *Database*: PostgreSQL (configured, ready for migration)
|
||||
- *Frontend*: HTML5, JavaScript, CLIP templating, LASS (CSS in Lisp)
|
||||
- *Infrastructure*: Docker, Docker Compose
|
||||
|
||||
* Project Timeline
|
||||
|
||||
** Phase 1: Project Inception (August 2025)
|
||||
|
||||
*** 2025-08-12: Initial Commit
|
||||
- *Author*: Brian O'Reilly (Fade)
|
||||
- Project founded and initial repository created
|
||||
- Basic project structure established
|
||||
- Core Radiance framework integration begun
|
||||
|
||||
** Phase 2: Foundation Building (September - Early October 2025)
|
||||
|
||||
*** Core Features Established
|
||||
- Basic web server setup with Radiance
|
||||
- Initial music library scanning functionality
|
||||
- Database integration for track metadata
|
||||
- Basic authentication system
|
||||
- Front-end page structure
|
||||
|
||||
*** Key Contributors Join
|
||||
- Glenn Thompson (glenneth) begins major contributions
|
||||
- Luis Pereira joins for UI/UX improvements
|
||||
- Collaborative development model established
|
||||
|
||||
** Phase 3: Template System & UI Overhaul (October 2025)
|
||||
|
||||
*** 2025-10-04 to 2025-10-06: CLIP Template Migration
|
||||
- *Lead*: Luis Pereira, Glenn Thompson
|
||||
- Migrated from inline HTML to CLIP templating system
|
||||
- Established consistent site-wide styling
|
||||
- Implemented VT323 retro terminal font
|
||||
- Created reusable template components
|
||||
|
||||
*** 2025-10-04 to 2025-10-07: User Management System
|
||||
- *Lead*: Glenn Thompson
|
||||
- User profile pages with edit functionality
|
||||
- Registration and authentication UI
|
||||
- Role-based access control (admin, DJ, listener)
|
||||
- User profile management interface
|
||||
|
||||
*** 2025-10-05: Navigation Improvements
|
||||
- *Lead*: Luis Pereira
|
||||
- Unified navigation bar across all pages
|
||||
- Improved responsive design
|
||||
- Better mobile experience
|
||||
|
||||
** Phase 4: API Refactoring & Testing (October 8-10, 2025)
|
||||
|
||||
*** 2025-10-08: Major API Overhaul
|
||||
- *Lead*: Glenn Thompson
|
||||
- Refactored all endpoints to use Radiance's define-api macro
|
||||
- Standardized JSON API responses
|
||||
- API-aware authentication (auto-detects API vs web requests)
|
||||
- Comprehensive automated test suite added
|
||||
|
||||
*** 2025-10-08 to 2025-10-09: Frontend JavaScript Updates
|
||||
- Fixed all frontend code to work with new API endpoints
|
||||
- Improved error handling
|
||||
- Better async/await patterns
|
||||
|
||||
*** 2025-10-10: Documentation Sprint
|
||||
- *Lead*: Glenn Thompson
|
||||
- Major documentation cleanup
|
||||
- Added comprehensive API documentation
|
||||
- Created testing guides
|
||||
- Updated all core documentation files
|
||||
|
||||
** Phase 5: Streaming Infrastructure (October 8-14, 2025)
|
||||
|
||||
*** 2025-10-08: Liquidsoap DJ Controls
|
||||
- *Lead*: Glenn Thompson
|
||||
- Telnet integration with Liquidsoap
|
||||
- Real-time stream control
|
||||
- Skip track functionality
|
||||
- Queue management via telnet commands
|
||||
|
||||
*** 2025-10-10: Dynamic Stream URL Support
|
||||
- *Lead*: Glenn Thompson
|
||||
- Stream base URL as template variable
|
||||
- Support for multiple deployment environments
|
||||
- Preparation for multi-network access
|
||||
|
||||
*** 2025-10-14: Stream Queue System
|
||||
- *Lead*: Brian O'Reilly, Glenn Thompson
|
||||
- M3U playlist queue management
|
||||
- Admin UI for queue control
|
||||
- Add/remove tracks from stream queue
|
||||
- Real-time queue updates
|
||||
|
||||
*** 2025-10-14: Audio Quality Improvements
|
||||
- ReplayGain volume normalization
|
||||
- Reduced buffering
|
||||
- Improved player UI
|
||||
- Better streaming performance
|
||||
|
||||
** Phase 6: Advanced Features (October 12-17, 2025)
|
||||
|
||||
*** 2025-10-12: Role-Based Page Flow
|
||||
- *Lead*: Glenn Thompson
|
||||
- Intelligent page routing based on user role
|
||||
- Admin-specific workflows
|
||||
- DJ control interfaces
|
||||
- Enhanced user experience
|
||||
|
||||
*** 2025-10-13: HTML Partial Hydration
|
||||
- *Lead*: Luis Pereira
|
||||
- Now-playing partial component
|
||||
- Server-side rendering with client updates
|
||||
- Reduced JavaScript complexity
|
||||
- Better performance
|
||||
|
||||
*** 2025-10-15 to 2025-10-16: Configuration System
|
||||
- *Lead*: Brian O'Reilly
|
||||
- Dedicated configuration namespace exploration
|
||||
- Environment-based configuration
|
||||
- Improved deployment flexibility
|
||||
|
||||
*** 2025-10-16: Comprehensive Documentation Update
|
||||
- *Lead*: Glenn Thompson
|
||||
- PROJECT-OVERVIEW updated with all features
|
||||
- Stream queue and ReplayGain documentation
|
||||
- Complete feature documentation
|
||||
|
||||
*** 2025-10-17: Code Quality Improvements
|
||||
- *Lead*: Glenn Thompson
|
||||
- Code consistency refactoring
|
||||
- Bug fixes (track search query variable)
|
||||
- Maintainability improvements
|
||||
- Better code organization
|
||||
|
||||
** Phase 7: Player Evolution (October 19-25, 2025)
|
||||
|
||||
*** 2025-10-19: Pop-Out Player
|
||||
- *Lead*: Glenn Thompson
|
||||
- Standalone pop-out player window
|
||||
- Independent audio playback
|
||||
- Queue management improvements
|
||||
- Multi-window support
|
||||
|
||||
*** 2025-10-19: Persistent Audio Player (Frameset)
|
||||
- *Lead*: Glenn Thompson
|
||||
- Frameset-based persistent player
|
||||
- Audio continues during navigation
|
||||
- Bottom-frame player bar
|
||||
- Seamless listening experience
|
||||
|
||||
*** 2025-10-21: Hybrid Player System
|
||||
- *Lead*: Glenn Thompson
|
||||
- Combined frameset and pop-out options
|
||||
- User preference storage (localStorage)
|
||||
- Flexible playback modes
|
||||
- Enhanced user choice
|
||||
|
||||
*** 2025-10-24: Dynamic Stream URL Detection
|
||||
- *Lead*: Glenn Thompson
|
||||
- Automatic host detection from HTTP headers
|
||||
- Multi-environment support (localhost, Tailscale, LAN)
|
||||
- Fixed remote access issues
|
||||
- No configuration needed for different networks
|
||||
|
||||
*** 2025-10-25: Typography Consistency Fix
|
||||
- *Lead*: Glenn Thompson
|
||||
- Replaced Courier New with VT323 in persistent player
|
||||
- Consistent font usage site-wide
|
||||
- Addressed styling feedback
|
||||
- Improved visual coherence
|
||||
|
||||
** Phase 8: Docker Deployment & Documentation (October 26 - November 1, 2025)
|
||||
|
||||
*** 2025-10-19: User Initialization Retry Logic
|
||||
- *Lead*: Luis Pereira (easilok)
|
||||
- Fixed user initialization retry mechanism
|
||||
- Improved reliability on startup
|
||||
- Better error handling
|
||||
|
||||
*** 2025-10-26: Custom Environment Variables for Streams
|
||||
- *Lead*: Luis Pereira (easilok)
|
||||
- Added MUSIC_LIBRARY environment variable
|
||||
- Added QUEUE_PLAYLIST environment variable
|
||||
- Flexible path configuration for Docker deployments
|
||||
|
||||
*** 2025-10-26: Docker Setup for Asteroid Application
|
||||
- *Lead*: Luis Pereira (easilok)
|
||||
- Created Dockerfile.asteroid for app containerization
|
||||
- Added docker-compose.asteroid.yml
|
||||
- Radiance configuration for containerized deployment
|
||||
- Complete Docker-based deployment solution
|
||||
|
||||
*** 2025-10-26: Docker Deployment Documentation
|
||||
- *Lead*: Luis Pereira (easilok)
|
||||
- Comprehensive Docker deployment guide in INSTALLATION.org
|
||||
- Separate sections for stream services and application
|
||||
- Environment variable documentation
|
||||
- Build and deployment instructions
|
||||
|
||||
*** 2025-10-26: Comprehensive Documentation Update
|
||||
- *Lead*: Glenn Thompson
|
||||
- Created PROJECT-HISTORY.org with complete timeline
|
||||
- Updated all documentation dates to 2025-10-26
|
||||
- Added current features across all docs
|
||||
- Updated repository URLs to GitHub
|
||||
- Documentation version 3.0
|
||||
|
||||
*** 2025-10-28: Documentation Refinements
|
||||
- *Lead*: Glenn Thompson
|
||||
- Fixed music directory location (asteroid/music/ not docker/music/)
|
||||
- Removed redundant Python/JavaScript examples from API docs
|
||||
- Added package manager notes for cross-distribution compatibility
|
||||
- Clarified symlink support for music directories
|
||||
|
||||
*** 2025-11-01: Documentation Merge and Cleanup
|
||||
- *Lead*: Glenn Thompson
|
||||
- Merged upstream Docker deployment documentation
|
||||
- Removed obsolete session notes
|
||||
- Synchronized with upstream/main
|
||||
- Prepared comprehensive documentation PR
|
||||
|
||||
* Development Statistics
|
||||
|
||||
** Contributors (by commit count)
|
||||
1. Glenn Thompson (glenneth/Glenneth) - 135+ commits
|
||||
2. Brian O'Reilly (Fade) - 55+ commits
|
||||
3. Luis Pereira (easilok) - 23+ commits
|
||||
|
||||
** Total Commits: 213+ commits
|
||||
|
||||
** Active Development Period
|
||||
- Start: August 12, 2025
|
||||
- Current: November 1, 2025
|
||||
- Duration: ~2.75 months of active development
|
||||
|
||||
* Major Features Implemented
|
||||
|
||||
** Core Functionality
|
||||
- ✅ Music library scanning and metadata extraction
|
||||
- ✅ PostgreSQL database integration (configured, ready for migration)
|
||||
- ✅ Track search and filtering
|
||||
- ✅ Playlist management
|
||||
- ✅ Stream queue control
|
||||
- ✅ Live streaming via Icecast/Liquidsoap
|
||||
|
||||
** User Management
|
||||
- ✅ User registration and authentication
|
||||
- ✅ Role-based access control (Admin, DJ, Listener)
|
||||
- ✅ User profiles with edit functionality
|
||||
- ✅ Session management
|
||||
- ✅ Role-based page flow
|
||||
|
||||
** Streaming Features
|
||||
- ✅ Multiple quality options (AAC 96k, MP3 128k, MP3 64k)
|
||||
- ✅ ReplayGain volume normalization
|
||||
- ✅ Live now-playing information
|
||||
- ✅ Icecast integration
|
||||
- ✅ Liquidsoap DJ controls
|
||||
- ✅ Stream queue management
|
||||
|
||||
** Player Options
|
||||
- ✅ Inline web player
|
||||
- ✅ Pop-out player window
|
||||
- ✅ Persistent frameset player
|
||||
- ✅ Hybrid player system
|
||||
- ✅ Quality selector
|
||||
- ✅ Auto-reconnect on errors
|
||||
|
||||
** API & Integration
|
||||
- ✅ RESTful JSON API
|
||||
- ✅ API-aware authentication
|
||||
- ✅ Comprehensive test suite
|
||||
- ✅ Telnet integration with Liquidsoap
|
||||
- ✅ Real-time status updates
|
||||
|
||||
** UI/UX
|
||||
- ✅ Retro terminal aesthetic (VT323 font)
|
||||
- ✅ Responsive design
|
||||
- ✅ CLIP templating system
|
||||
- ✅ LASS CSS preprocessing
|
||||
- ✅ Consistent navigation
|
||||
- ✅ HTML partial hydration
|
||||
|
||||
** Infrastructure
|
||||
- ✅ Docker containerization (streams and application)
|
||||
- ✅ Docker Compose orchestration
|
||||
- ✅ Dockerfile for Asteroid application
|
||||
- ✅ Environment variable configuration
|
||||
- ✅ PostgreSQL database (configured)
|
||||
- ✅ Multi-environment support
|
||||
- ✅ Dynamic URL detection
|
||||
|
||||
* Technical Milestones
|
||||
|
||||
** Architecture Evolution
|
||||
1. *Initial*: Monolithic HTML generation
|
||||
2. *Template Migration*: CLIP templating system
|
||||
3. *API Standardization*: Radiance define-api macros
|
||||
4. *Component Architecture*: HTML partials and hydration
|
||||
5. *Multi-Mode Player*: Hybrid player system
|
||||
|
||||
** Code Quality Improvements
|
||||
- Comprehensive test suite
|
||||
- API refactoring for consistency
|
||||
- Code organization and maintainability
|
||||
- Documentation standards
|
||||
- Consistent error handling
|
||||
|
||||
** Performance Optimizations
|
||||
- ReplayGain normalization
|
||||
- Reduced buffering
|
||||
- Efficient database queries
|
||||
- Parallel music scanning
|
||||
- Client-side caching
|
||||
|
||||
* Current State (November 2025)
|
||||
|
||||
** Production Ready Features
|
||||
- Full music streaming platform
|
||||
- User management system
|
||||
- Admin control panel
|
||||
- DJ controls
|
||||
- Multiple player modes
|
||||
- Complete Docker deployment (streams + application)
|
||||
- Multi-environment support with dynamic URLs
|
||||
- Comprehensive documentation
|
||||
|
||||
** Active Development Areas
|
||||
- PostgreSQL migration (configured, ready for data migration)
|
||||
- JavaScript code cleanup and refactoring
|
||||
- Additional UI improvements
|
||||
- Performance optimization
|
||||
- Feature expansion based on user feedback
|
||||
|
||||
** Recent Achievements
|
||||
- ✅ Complete Docker containerization
|
||||
- ✅ Environment variable configuration
|
||||
- ✅ Comprehensive documentation overhaul
|
||||
- ✅ Cross-distribution package manager support
|
||||
- ✅ Streamlined deployment process
|
||||
|
||||
** Known Issues & Future Work
|
||||
- PostgreSQL migration (configured, pending data migration)
|
||||
- Continued UI/UX refinement
|
||||
- Additional streaming features (per design.org)
|
||||
- Enhanced playlist functionality
|
||||
- Live chat and song requests
|
||||
- Mobile app considerations
|
||||
- Scalability improvements
|
||||
|
||||
* Project Philosophy
|
||||
|
||||
** Design Principles
|
||||
- *Hacker Aesthetic*: Terminal-inspired retro design
|
||||
- *User Choice*: Multiple player modes and options
|
||||
- *Simplicity*: Clean, focused interface
|
||||
- *Performance*: Fast, responsive experience
|
||||
- *Flexibility*: Multi-environment support
|
||||
|
||||
** Development Approach
|
||||
- Collaborative development
|
||||
- Iterative improvements
|
||||
- Comprehensive testing
|
||||
- Documentation-first
|
||||
- User feedback driven
|
||||
|
||||
* Acknowledgments
|
||||
|
||||
** Core Team
|
||||
- *Brian O'Reilly (Fade)*: Project founder, architecture, streaming infrastructure
|
||||
- *Glenn Thompson (glenneth)*: Major features, API, player systems, documentation
|
||||
- *Luis Pereira*: UI/UX, templating, frontend improvements
|
||||
|
||||
** Technologies
|
||||
- Radiance web framework
|
||||
- Icecast streaming server
|
||||
- Liquidsoap audio processing
|
||||
- PostgreSQL database
|
||||
- Common Lisp ecosystem
|
||||
|
||||
* Conclusion
|
||||
|
||||
Asteroid Radio has evolved from a simple concept into a full-featured internet radio platform in just 2.75 months of active development. The project demonstrates the power of Common Lisp for web development and the collaborative nature of open-source development.
|
||||
|
||||
With complete Docker deployment, comprehensive documentation, and a growing feature set, Asteroid Radio is ready for production use while continuing to evolve with regular improvements, bug fixes, and new features based on user needs and technical requirements.
|
||||
|
||||
** Project Links
|
||||
- Repository: https://github.com/fade/asteroid
|
||||
- Contributors: https://github.com/fade/asteroid/graphs/contributors
|
||||
- IRC: #asteroid.music on irc.libera.chat
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-11-01*
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
#+TITLE: Asteroid Radio - Project Overview
|
||||
#+AUTHOR: Glenn Thompson & Brian O'Reilly (Fade)
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* 🎯 Mission
|
||||
|
||||
Asteroid Radio is a modern, web-based music streaming platform designed for hackers and music enthusiasts. Built with Common Lisp and the Radiance web framework, it combines the power of functional programming with contemporary web technologies.
|
||||
|
||||
* 🏗️ Architecture
|
||||
|
||||
** Core Components
|
||||
|
||||
#+BEGIN_EXAMPLE
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Asteroid Radio Platform │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Web Application Layer (Common Lisp + Radiance) │
|
||||
│ ├── Authentication & User Management │
|
||||
│ ├── Music Library Management │
|
||||
│ ├── Web Player Interface │
|
||||
│ └── API Endpoints │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Streaming Infrastructure (Docker) │
|
||||
│ ├── Icecast2 (Streaming Server) │
|
||||
│ ├── Liquidsoap (Audio Processing) │
|
||||
│ └── Multiple Format Support (AAC, MP3) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Data Layer │
|
||||
│ ├── PostgreSQL Database (via Radiance) │
|
||||
│ ├── User Accounts & Profiles │
|
||||
│ └── Music Metadata │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
|
||||
**Backend:**
|
||||
- **Common Lisp** (SBCL) - Core application language
|
||||
- **Radiance Framework** - Web framework and module system
|
||||
- **LASS** - CSS preprocessing in Lisp
|
||||
- **PostgreSQL** - Database backend (configured, ready for migration)
|
||||
- **Radiance DB** - Current database abstraction layer
|
||||
|
||||
**Frontend:**
|
||||
- **HTML5** with semantic templates
|
||||
- **CSS3** with dark hacker theme
|
||||
- **JavaScript** for interactive features
|
||||
- **VT323 Font** for retro terminal aesthetic
|
||||
|
||||
**Streaming:**
|
||||
- **Docker Compose** - Container orchestration
|
||||
- **Icecast2** - HTTP streaming server
|
||||
- **Liquidsoap** - Audio processing and encoding
|
||||
- **Multiple Formats** - AAC 96kbps, MP3 128kbps/64kbps
|
||||
|
||||
**Development & Testing:**
|
||||
- **Make** - Build system
|
||||
- **Python** - Performance analysis tools
|
||||
- **Bash** - Testing and deployment scripts
|
||||
|
||||
## 🎨 Design Philosophy
|
||||
|
||||
### Visual Theme
|
||||
- **Dark terminal aesthetic** - Black background with colored text
|
||||
- **Hacker-friendly** - Monospace fonts and terminal-inspired UI
|
||||
- **Color scheme** - Black → Blue-grey → Cyan → Blue progression
|
||||
- **Minimalist** - Clean, functional interface without clutter
|
||||
|
||||
### Technical Principles
|
||||
- **Functional programming** - Leveraging Lisp's strengths
|
||||
- **Modular architecture** - Radiance's interface system
|
||||
- **Performance first** - Sub-1% CPU usage for web app
|
||||
- **Self-contained** - Minimal external dependencies
|
||||
- **Docker-ready** - Containerized streaming infrastructure
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### Current Features
|
||||
- ✅ **User Authentication** - Registration, login, profiles, role-based access (Admin/DJ/Listener)
|
||||
- ✅ **User Management** - Admin interface for user administration
|
||||
- ✅ **Music Library** - Track management with pagination, search, and filtering
|
||||
- ✅ **User Playlists** - Create, manage, and play personal music collections
|
||||
- ✅ **Multiple Player Modes** - Inline, pop-out, and persistent frameset players
|
||||
- ✅ **Stream Queue Control** - Admin control over broadcast stream queue (M3U-based)
|
||||
- ✅ **REST API** - Comprehensive JSON API with 15+ endpoints
|
||||
- ✅ **Music Streaming** - Multiple quality formats (128k MP3, 96k AAC, 64k MP3)
|
||||
- ✅ **Rate Limiting** - Anti-abuse protection
|
||||
- ✅ **Docker Integration** - Icecast2/Liquidsoap streaming infrastructure
|
||||
- ✅ **PostgreSQL Database** - Configured and ready for migration
|
||||
- ✅ **Liquidsoap DJ Controls** - Telnet interface for live control
|
||||
- ✅ **Dynamic Stream URLs** - Automatic host detection for multi-environment support
|
||||
- ✅ **ReplayGain Normalization** - Consistent audio volume across tracks
|
||||
- ✅ **Responsive Design** - Works on desktop and mobile
|
||||
- ✅ **Automated Testing** - Comprehensive test suite
|
||||
|
||||
### Planned Features
|
||||
- 🔄 **PostgreSQL Migration** - Full migration from Radiance DB to PostgreSQL
|
||||
- 🔄 **Enhanced Playlist Management** - Full CRUD operations with PostgreSQL
|
||||
- 🔄 **Social Features** - Playlist sharing and discovery
|
||||
- 🔄 **Advanced Search** - Full-text search and filtering
|
||||
- 🔄 **Mobile App** - Native mobile applications
|
||||
- 🔄 **WebSocket Support** - Real-time updates
|
||||
- 🔄 **Analytics** - Listening statistics and insights
|
||||
- 🔄 **Scheduled Programming** - Time-based queue switching
|
||||
|
||||
|
||||
## 🔮 Vision
|
||||
|
||||
Asteroid Radio is the premier streaming platform for **Asteroid Music** - the perfect soundtrack for developers, hackers, and anyone who spends hours deep in code. Our mission is to curate and deliver music that enhances focus, creativity, and the flow state that every programmer knows.
|
||||
|
||||
**What is Asteroid Music?**
|
||||
- **Focus-Enhancing** - Ambient, electronic, and instrumental tracks that don't distract
|
||||
- **Coding-Optimized** - Rhythms and textures that complement the mental rhythm of programming
|
||||
- **Hacker Culture** - Music that resonates with the developer mindset and aesthetic
|
||||
- **Flow State** - Carefully selected tracks that help maintain deep concentration
|
||||
|
||||
**Platform Features:**
|
||||
- **Multi-Format Streaming** - High-quality AAC, MP3 128k, and MP3 64k streams
|
||||
- **User Community** - Accounts, playlists, and sharing among fellow developers
|
||||
- **Developer-Friendly** - Built with Common Lisp, fully hackable and extensible
|
||||
- **Professional Quality** - Crossfading, normalization, metadata, and telnet control
|
||||
- **Always-On Broadcasting** - Continuous streams perfect for long coding sessions
|
||||
|
||||
Asteroid Radio isn't just another music platform - it's the soundtrack to the hacker lifestyle, designed by hackers for hackers who understand that the right music can make the difference between good code and great code.
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
#+TITLE: Asteroid Radio - Documentation Index
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Welcome to Asteroid Radio Documentation
|
||||
|
||||
Asteroid Radio is a modern internet radio platform designed for developers and music enthusiasts who want to run their own radio stations streaming **Asteroid Music** - the perfect soundtrack for coding and hacking sessions.
|
||||
|
||||
* Quick Start
|
||||
|
||||
For immediate setup, see:
|
||||
1. **[[file:INSTALLATION.org][Installation Guide]]** - Get Asteroid Radio running
|
||||
2. **[[file:DOCKER-STREAMING.org][Docker Streaming Setup]]** - Docker-based streaming infrastructure
|
||||
|
||||
* Documentation Structure
|
||||
|
||||
** Core Documentation
|
||||
|
||||
*** [[file:PROJECT-OVERVIEW.org][Project Overview]]
|
||||
Complete overview of Asteroid Radio's architecture, technology stack, and vision. Start here to understand what Asteroid Radio is and how it works.
|
||||
|
||||
*** [[file:PROJECT-HISTORY.org][Project History]]
|
||||
Comprehensive development history from inception to present, including timeline, milestones, and contributor information.
|
||||
|
||||
*** [[file:INSTALLATION.org][Installation Guide]]
|
||||
Comprehensive installation instructions for multiple operating systems, including system requirements, dependencies, and production deployment considerations.
|
||||
|
||||
*** [[file:DOCKER-STREAMING.org][Docker Streaming Setup]]
|
||||
Complete guide to the Docker-based streaming infrastructure using Icecast2 and Liquidsoap. Includes container configuration, management scripts, and troubleshooting.
|
||||
|
||||
** Development & Integration
|
||||
|
||||
*** [[file:DEVELOPMENT.org][Development Guide]]
|
||||
Development environment setup, contributing guidelines, coding standards, and debugging procedures for developers working on Asteroid Radio.
|
||||
|
||||
*** [[file:API-ENDPOINTS.org][API Endpoints Reference]]
|
||||
Complete documentation of all REST API endpoints including authentication, tracks, playlists, player control, and admin functions.
|
||||
|
||||
*** [[file:API-REFERENCE.org][Interface Reference]]
|
||||
Documentation of streaming endpoints, Icecast admin interface, Liquidsoap telnet control, and Docker management commands.
|
||||
|
||||
*** [[file:TESTING.org][Testing Guide]]
|
||||
Automated testing suite documentation, test script usage, and manual testing procedures.
|
||||
|
||||
** Feature Documentation
|
||||
|
||||
*** [[file:POSTGRESQL-SETUP.org][PostgreSQL Setup]]
|
||||
Database configuration, schema design, and migration guide for PostgreSQL backend.
|
||||
|
||||
*** [[file:PLAYLIST-SYSTEM.org][Playlist System]]
|
||||
User playlist functionality including creation, management, and playback features.
|
||||
|
||||
*** [[file:USER-MANAGEMENT-SYSTEM.org][User Management]]
|
||||
User administration system with role management, authentication, and access control.
|
||||
|
||||
*** [[file:TRACK-PAGINATION-SYSTEM.org][Track Pagination]]
|
||||
Pagination system for efficient browsing of large music libraries.
|
||||
|
||||
* Current System Status
|
||||
|
||||
** What's Working Now
|
||||
- **Web Application**: Full-featured web interface with authentication
|
||||
- **REST API**: JSON API with 15+ endpoints for programmatic access
|
||||
- **User Management**: Registration, login, roles (Admin/DJ/Listener), and profiles
|
||||
- **Music Library**: Track management with pagination, search, and filtering
|
||||
- **Playlists**: User playlists with creation and playback
|
||||
- **Multiple Player Modes**: Inline, pop-out, and persistent frameset players
|
||||
- **Stream Queue Control**: Admin control over broadcast stream queue
|
||||
- **Docker Streaming Infrastructure**: Icecast2 + Liquidsoap containers
|
||||
- **Three Quality Streams**: 128kbps MP3, 96kbps AAC, 64kbps MP3
|
||||
- **Admin Interface**: Icecast web admin at http://localhost:8000/admin/
|
||||
- **Liquidsoap DJ Controls**: Telnet control via localhost:1234
|
||||
- **Professional Features**: Crossfading, ReplayGain normalization, metadata support
|
||||
- **PostgreSQL Database**: Configured and ready for migration
|
||||
- **Dynamic Stream URLs**: Automatic host detection for multi-environment support
|
||||
- **Responsive Design**: Works on desktop and mobile devices
|
||||
|
||||
** Stream URLs (when running)
|
||||
- **High Quality MP3**: http://localhost:8000/asteroid.mp3 (128kbps)
|
||||
- **High Quality AAC**: http://localhost:8000/asteroid.aac (96kbps)
|
||||
- **Low Quality MP3**: http://localhost:8000/asteroid-low.mp3 (64kbps)
|
||||
|
||||
* Getting Started
|
||||
|
||||
** New Users
|
||||
1. Read the **[[file:PROJECT-OVERVIEW.org][Project Overview]]** to understand Asteroid Radio
|
||||
2. Follow the **[[file:INSTALLATION.org][Installation Guide]]** for your operating system
|
||||
3. Set up streaming with the **[[file:DOCKER-STREAMING.org][Docker Guide]]**
|
||||
|
||||
** Developers
|
||||
1. Review the **[[file:DEVELOPMENT.org][Development Guide]]** for setup procedures
|
||||
2. Check the **[[file:API-ENDPOINTS.org][API Endpoints Reference]]** for REST API documentation
|
||||
3. Review the **[[file:API-REFERENCE.org][Interface Reference]]** for streaming controls
|
||||
4. See **[[file:TESTING.org][Testing Guide]]** for automated testing
|
||||
5. Join our IRC channel: **#asteroid.music** on **irc.libera.chat**
|
||||
|
||||
** System Administrators
|
||||
1. Follow the **[[file:INSTALLATION.org][Installation Guide]]** production deployment section
|
||||
2. Review **[[file:DOCKER-STREAMING.org][Docker Streaming Setup]]** for container management
|
||||
3. Monitor system resources and streaming performance
|
||||
|
||||
* Support & Community
|
||||
|
||||
** Getting Help
|
||||
- **Documentation**: Start with the relevant guide above
|
||||
- **IRC Chat**: Join **#asteroid.music** on **irc.libera.chat**
|
||||
- **Issues**: Submit detailed bug reports with system information
|
||||
- **Logs**: Check Docker container logs for troubleshooting
|
||||
|
||||
** Contributing
|
||||
- Review the **[[file:DEVELOPMENT.org][Development Guide]]** for contribution guidelines
|
||||
- Follow coding standards and testing procedures
|
||||
- Submit pull requests with clear descriptions
|
||||
|
||||
* About Asteroid Music
|
||||
|
||||
Asteroid Radio streams **Asteroid Music** - a carefully curated genre designed for developers:
|
||||
|
||||
- **Focus-Enhancing**: Ambient, electronic, and instrumental tracks
|
||||
- **Coding-Optimized**: Rhythms that complement programming flow
|
||||
- **Hacker Culture**: Music that resonates with developer aesthetics
|
||||
- **Flow State**: Tracks selected to maintain deep concentration
|
||||
|
||||
This isn't just background music - it's the soundtrack to the hacker lifestyle, designed by hackers for hackers who understand that the right music can elevate your code.
|
||||
|
||||
* Technical Architecture
|
||||
|
||||
Asteroid Radio uses a modern, containerized architecture:
|
||||
|
||||
#+BEGIN_EXAMPLE
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Asteroid Radio Platform │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Streaming Infrastructure (Docker) │
|
||||
│ ├── Icecast2 (HTTP Streaming Server) │
|
||||
│ ├── Liquidsoap (Audio Processing Pipeline) │
|
||||
│ └── Multiple Format Support (AAC, MP3) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Control Interfaces │
|
||||
│ ├── Icecast Admin Web Interface │
|
||||
│ ├── Liquidsoap Telnet Control │
|
||||
│ └── Docker Container Management │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
#+END_EXAMPLE
|
||||
|
||||
For detailed technical information, see the **[[file:PROJECT-OVERVIEW.org][Project Overview]]**.
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-10-26*
|
||||
*Documentation Version: 3.0*
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
#+TITLE: Stream Queue Control System
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
The stream queue control system allows administrators to manage what plays on the main Asteroid Radio broadcast stream. Instead of random playback from the music library, you can now curate the exact order of tracks.
|
||||
|
||||
* How It Works
|
||||
|
||||
1. *Stream Queue* - An ordered list of track IDs maintained in memory
|
||||
2. *M3U Generation* - The queue is converted to a =stream-queue.m3u= file
|
||||
3. *Liquidsoap Integration* - Liquidsoap reads the M3U file and reloads it every 60 seconds
|
||||
4. *Fallback* - If the queue is empty, Liquidsoap falls back to random directory playback
|
||||
|
||||
* API Endpoints (Admin Only)
|
||||
|
||||
All endpoints require admin authentication.
|
||||
|
||||
** Get Current Queue
|
||||
#+BEGIN_SRC
|
||||
GET /api/asteroid/stream/queue
|
||||
#+END_SRC
|
||||
|
||||
Returns the current stream queue with track details.
|
||||
|
||||
** Add Track to Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/add
|
||||
Parameters:
|
||||
- track_id: ID of track to add
|
||||
- position: "end" (default) or "next"
|
||||
#+END_SRC
|
||||
|
||||
Adds a track to the end of the queue or as the next track to play.
|
||||
|
||||
** Remove Track from Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/remove
|
||||
Parameters:
|
||||
- track_id: ID of track to remove
|
||||
#+END_SRC
|
||||
|
||||
Removes a specific track from the queue.
|
||||
|
||||
** Clear Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/clear
|
||||
#+END_SRC
|
||||
|
||||
Clears the entire queue (will fall back to random playback).
|
||||
|
||||
** Add Playlist to Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/add-playlist
|
||||
Parameters:
|
||||
- playlist_id: ID of playlist to add
|
||||
#+END_SRC
|
||||
|
||||
Adds all tracks from a user playlist to the stream queue.
|
||||
|
||||
** Reorder Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/reorder
|
||||
Parameters:
|
||||
- track_ids: Comma-separated list of track IDs in desired order
|
||||
#+END_SRC
|
||||
|
||||
Completely reorders the queue with a new track order.
|
||||
|
||||
* Usage Examples
|
||||
|
||||
** Building a Stream Queue
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# Add a specific track to the end
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add \
|
||||
-d "track-id=42" \
|
||||
-H "Cookie: radiance-session=..."
|
||||
|
||||
# Add a track to play next
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add \
|
||||
-d "track-id=43&position=next" \
|
||||
-H "Cookie: radiance-session=..."
|
||||
|
||||
# Add an entire playlist
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add-playlist \
|
||||
-d "playlist-id=5" \
|
||||
-H "Cookie: radiance-session=..."
|
||||
#+END_SRC
|
||||
|
||||
** Managing the Queue
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# View current queue
|
||||
curl http://localhost:8080/api/asteroid/stream/queue \
|
||||
-H "Cookie: radiance-session=..."
|
||||
|
||||
# Remove a track
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/remove \
|
||||
-d "track-id=42" \
|
||||
-H "Cookie: radiance-session=..."
|
||||
|
||||
# Clear everything
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/clear \
|
||||
-H "Cookie: radiance-session=..."
|
||||
#+END_SRC
|
||||
|
||||
* Lisp Functions
|
||||
|
||||
If you're working directly in the Lisp REPL:
|
||||
|
||||
#+BEGIN_SRC lisp
|
||||
;; Add tracks to queue
|
||||
(add-to-stream-queue 42 :end)
|
||||
(add-to-stream-queue 43 :next)
|
||||
|
||||
;; View queue
|
||||
(get-stream-queue)
|
||||
|
||||
;; Add a playlist
|
||||
(add-playlist-to-stream-queue 5)
|
||||
|
||||
;; Remove a track
|
||||
(remove-from-stream-queue 42)
|
||||
|
||||
;; Clear queue
|
||||
(clear-stream-queue)
|
||||
|
||||
;; Reorder queue
|
||||
(reorder-stream-queue '(43 44 45 46))
|
||||
|
||||
;; Build smart queues
|
||||
(build-smart-queue "electronic" 20)
|
||||
(build-queue-from-artist "Nine Inch Nails" 15)
|
||||
|
||||
;; Manually regenerate playlist file
|
||||
(regenerate-stream-playlist)
|
||||
#+END_SRC
|
||||
|
||||
* File Locations
|
||||
|
||||
- *Stream Queue File*: =stream-queue.m3u= (in project root)
|
||||
- *Docker Mount*: =/app/stream-queue.m3u= (inside Liquidsoap container)
|
||||
- *Liquidsoap Config*: =docker/asteroid-radio-docker.liq=
|
||||
|
||||
* How Liquidsoap Reads Updates
|
||||
|
||||
The Liquidsoap configuration reloads the playlist file every 60 seconds:
|
||||
|
||||
#+BEGIN_SRC liquidsoap
|
||||
radio = playlist.safe(
|
||||
mode="normal",
|
||||
reload=60,
|
||||
"/app/stream-queue.m3u"
|
||||
)
|
||||
#+END_SRC
|
||||
|
||||
This means changes to the queue will take effect within 1 minute.
|
||||
|
||||
* Stream History
|
||||
|
||||
The system also tracks recently played tracks:
|
||||
|
||||
#+BEGIN_SRC lisp
|
||||
;; Get last 10 played tracks
|
||||
(get-stream-history 10)
|
||||
|
||||
;; Add to history (usually automatic)
|
||||
(add-to-stream-history 42)
|
||||
#+END_SRC
|
||||
|
||||
* Future Enhancements
|
||||
|
||||
- [ ] Web UI for queue management (drag-and-drop reordering)
|
||||
- [ ] Telnet integration for real-time skip/next commands
|
||||
- [ ] Scheduled programming (time-based queue switching)
|
||||
- [ ] Auto-queue filling (automatically add tracks when queue runs low)
|
||||
- [ ] Genre-based smart queues
|
||||
- [ ] Listener request system
|
||||
|
||||
* Troubleshooting
|
||||
|
||||
** Queue changes not taking effect
|
||||
|
||||
- Wait up to 60 seconds for Liquidsoap to reload
|
||||
- Check that =stream-queue.m3u= was generated correctly
|
||||
- Verify Docker volume mount is working: =docker exec asteroid-liquidsoap ls -la /app/stream-queue.m3u=
|
||||
- Check Liquidsoap logs: =docker logs asteroid-liquidsoap=
|
||||
|
||||
** Empty queue falls back to random
|
||||
|
||||
This is expected behavior. The system will play random tracks from the music library when the queue is empty to ensure continuous streaming.
|
||||
|
||||
** Playlist file not updating
|
||||
|
||||
- Ensure Asteroid server has write permissions to the project directory
|
||||
- Check that =regenerate-stream-playlist= is being called after queue modifications
|
||||
- Verify the file exists: =ls -la stream-queue.m3u=
|
||||
|
||||
* Integration with Admin Interface
|
||||
|
||||
The stream control system is designed to be integrated into the admin web interface. Future work will add:
|
||||
|
||||
- Visual queue editor with drag-and-drop
|
||||
- "Add to Stream Queue" buttons on track listings
|
||||
- "Queue Playlist" buttons on playlist pages
|
||||
- Real-time queue display showing what's currently playing
|
||||
- Skip/Next controls for immediate playback changes (via Telnet)
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
#+TITLE: Asteroid Radio Testing Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
This document describes the automated testing system for Asteroid Radio.
|
||||
|
||||
#+BEGIN_QUOTE
|
||||
*Note on Package Managers*: Examples use =apt= (Debian/Ubuntu). Replace with your distribution's package manager (=dnf=, =pacman=, =zypper=, =apk=, etc.).
|
||||
#+END_QUOTE
|
||||
|
||||
* Test Script
|
||||
|
||||
The =test-server.sh= script provides comprehensive testing of all server endpoints and functionality.
|
||||
|
||||
** Features
|
||||
|
||||
- Tests all API endpoints (15 endpoints)
|
||||
- Tests HTML page rendering (5 pages)
|
||||
- Tests static file serving
|
||||
- Validates JSON response format
|
||||
- Color-coded output for easy reading
|
||||
- Detailed pass/fail reporting
|
||||
- Verbose mode for debugging
|
||||
|
||||
** Usage
|
||||
|
||||
*** Basic Usage
|
||||
#+BEGIN_SRC bash
|
||||
# Test local server (default: http://localhost:8080)
|
||||
./test-server.sh
|
||||
|
||||
# Verbose mode (shows response details)
|
||||
./test-server.sh -v
|
||||
|
||||
# Test remote server
|
||||
./test-server.sh -u http://example.com
|
||||
|
||||
# Show help
|
||||
./test-server.sh -h
|
||||
#+END_SRC
|
||||
|
||||
*** Environment Variables
|
||||
#+BEGIN_SRC bash
|
||||
# Set base URL via environment
|
||||
ASTEROID_URL=http://example.com ./test-server.sh
|
||||
|
||||
# Enable verbose mode
|
||||
VERBOSE=1 ./test-server.sh
|
||||
#+END_SRC
|
||||
|
||||
** Test Categories
|
||||
|
||||
*** 1. Server Status
|
||||
- Server accessibility check
|
||||
- API response format validation
|
||||
|
||||
*** 2. Status Endpoints
|
||||
- =/api/asteroid/status= - Server status
|
||||
- =/api/asteroid/auth-status= - Authentication status
|
||||
- =/api/asteroid/icecast-status= - Icecast streaming status
|
||||
|
||||
*** 3. Track Endpoints
|
||||
- =/api/asteroid/tracks= - Track listing
|
||||
- =/api/asteroid/admin/tracks= - Admin track listing
|
||||
|
||||
*** 4. Player Control Endpoints
|
||||
- =/api/asteroid/player/status= - Player status
|
||||
- =/api/asteroid/player/play= - Play track
|
||||
- =/api/asteroid/player/pause= - Pause playback
|
||||
- =/api/asteroid/player/stop= - Stop playback
|
||||
- =/api/asteroid/player/resume= - Resume playback
|
||||
|
||||
*** 5. Playlist Endpoints
|
||||
- =/api/asteroid/playlists= - Playlist listing
|
||||
- =/api/asteroid/playlists/create= - Create playlist
|
||||
- =/api/asteroid/playlists/add-track= - Add track to playlist
|
||||
- =/api/asteroid/playlists/get= - Get playlist details
|
||||
|
||||
*** 6. Admin Endpoints
|
||||
- =/api/asteroid/admin/tracks= - Admin track listing
|
||||
- =/api/asteroid/admin/scan-library= - Library scan
|
||||
|
||||
*** 7. HTML Pages
|
||||
- =/asteroid/= - Front page
|
||||
- =/asteroid/admin= - Admin dashboard
|
||||
- =/asteroid/player= - Web player
|
||||
- =/asteroid/profile= - User profile
|
||||
- =/asteroid/register= - Registration page
|
||||
|
||||
*** 8. Static Files
|
||||
- CSS files (=/asteroid/static/*.css=)
|
||||
- JavaScript files (=/asteroid/static/js/*.js=)
|
||||
|
||||
** Example Output
|
||||
|
||||
#+BEGIN_EXAMPLE
|
||||
╔═══════════════════════════════════════╗
|
||||
║ Asteroid Radio Server Test Suite ║
|
||||
╔═══════════════════════════════════════╗
|
||||
|
||||
INFO: Testing server at: http://localhost:8080
|
||||
INFO: Verbose mode: 0
|
||||
|
||||
========================================
|
||||
Checking Server Status
|
||||
========================================
|
||||
|
||||
TEST: Server is accessible
|
||||
✓ PASS: Server is running at http://localhost:8080
|
||||
|
||||
========================================
|
||||
Testing API Response Format
|
||||
========================================
|
||||
|
||||
TEST: API returns JSON format
|
||||
✓ PASS: API returns JSON (not S-expressions)
|
||||
|
||||
========================================
|
||||
Testing Status Endpoints
|
||||
========================================
|
||||
|
||||
TEST: Server status endpoint
|
||||
✓ PASS: Server status endpoint - Response contains 'asteroid-radio'
|
||||
|
||||
TEST: Authentication status endpoint
|
||||
✓ PASS: Authentication status endpoint - Response contains 'loggedIn'
|
||||
|
||||
...
|
||||
|
||||
========================================
|
||||
Test Summary
|
||||
========================================
|
||||
|
||||
Tests Run: 25
|
||||
Tests Passed: 25
|
||||
Tests Failed: 0
|
||||
|
||||
✓ All tests passed!
|
||||
#+END_EXAMPLE
|
||||
|
||||
** Exit Codes
|
||||
|
||||
- =0= - All tests passed
|
||||
- =1= - One or more tests failed or server not accessible
|
||||
|
||||
** Requirements
|
||||
|
||||
*** Required
|
||||
- =bash= - Shell script interpreter
|
||||
- =curl= - HTTP client for testing endpoints
|
||||
|
||||
*** Optional
|
||||
- =jq= - JSON processor for advanced JSON validation (recommended)
|
||||
|
||||
Install jq:
|
||||
#+BEGIN_SRC bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install jq
|
||||
|
||||
# macOS
|
||||
brew install jq
|
||||
#+END_SRC
|
||||
|
||||
** Integration with CI/CD
|
||||
|
||||
The test script can be integrated into continuous integration pipelines:
|
||||
|
||||
#+BEGIN_SRC yaml
|
||||
# Example GitHub Actions workflow
|
||||
- name: Start Asteroid Server
|
||||
run: ./asteroid &
|
||||
|
||||
- name: Wait for server
|
||||
run: sleep 5
|
||||
|
||||
- name: Run tests
|
||||
run: ./test-server.sh
|
||||
#+END_SRC
|
||||
|
||||
** Extending the Tests
|
||||
|
||||
To add new tests, edit =test-server.sh= and add test functions:
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
test_my_new_feature() {
|
||||
print_header "Testing My New Feature"
|
||||
|
||||
test_api_endpoint "/my-endpoint" \
|
||||
"My endpoint description" \
|
||||
"expected-field"
|
||||
}
|
||||
|
||||
# Add to main() function
|
||||
main() {
|
||||
# ... existing tests ...
|
||||
test_my_new_feature
|
||||
# ...
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** Troubleshooting
|
||||
|
||||
*** Server not accessible
|
||||
- Ensure server is running: =./asteroid=
|
||||
- Check server is on correct port: =8080=
|
||||
- Verify firewall settings
|
||||
|
||||
*** Tests failing
|
||||
- Run with verbose mode: =./test-server.sh -v=
|
||||
- Check server logs for errors
|
||||
- Verify database is initialized
|
||||
- Ensure all dependencies are installed
|
||||
|
||||
*** JSON format issues
|
||||
- Verify JSON API format is configured in =asteroid.lisp=
|
||||
- Check =define-api-format json= is defined
|
||||
- Ensure =*default-api-format*= is set to ="json"=
|
||||
|
||||
* Manual Testing Checklist
|
||||
|
||||
For features not covered by automated tests:
|
||||
|
||||
** Authentication
|
||||
- [ ] Login with admin/asteroid123
|
||||
- [ ] Logout functionality
|
||||
- [ ] Session persistence
|
||||
- [ ] Protected pages redirect to login
|
||||
|
||||
** Music Library
|
||||
- [ ] Scan library adds tracks
|
||||
- [ ] Track metadata displays correctly
|
||||
- [ ] Audio streaming works
|
||||
- [ ] Search and filter tracks
|
||||
|
||||
** Playlists
|
||||
- [ ] Create new playlist
|
||||
- [ ] Add tracks to playlist
|
||||
- [ ] Load playlist
|
||||
- [ ] Delete playlist
|
||||
|
||||
** Player
|
||||
- [ ] Play/pause/stop controls work
|
||||
- [ ] Track progress updates
|
||||
- [ ] Queue management
|
||||
- [ ] Volume control
|
||||
|
||||
** Admin Features
|
||||
- [ ] View all tracks
|
||||
- [ ] Scan library
|
||||
- [ ] User management
|
||||
- [ ] System status monitoring
|
||||
|
||||
* Performance Testing
|
||||
|
||||
For load testing and performance validation:
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# Simple load test with Apache Bench
|
||||
ab -n 1000 -c 10 http://localhost:8080/api/asteroid/tracks
|
||||
|
||||
# Or with wrk
|
||||
wrk -t4 -c100 -d30s http://localhost:8080/api/asteroid/tracks
|
||||
#+END_SRC
|
||||
|
||||
* Security Testing
|
||||
|
||||
** API Security Checklist
|
||||
- [ ] Authentication required for protected endpoints
|
||||
- [ ] Authorization checks for admin endpoints
|
||||
- [ ] SQL injection prevention
|
||||
- [ ] XSS protection in templates
|
||||
- [ ] CSRF token validation
|
||||
- [ ] Rate limiting on API endpoints
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
#+TITLE: Track Pagination System - Complete
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
Implemented comprehensive pagination system for track listings in both admin dashboard and web player, handling 64+ tracks efficiently with configurable page sizes.
|
||||
|
||||
* What Was Completed
|
||||
|
||||
** Admin Dashboard Pagination
|
||||
- Paginated track management interface
|
||||
- Configurable tracks per page (10/20/50/100)
|
||||
- Navigation controls (First/Prev/Next/Last)
|
||||
- Page information display
|
||||
- Works with search and sort
|
||||
|
||||
** Web Player Pagination
|
||||
- Paginated personal track library
|
||||
- Configurable tracks per page (10/20/50)
|
||||
- Same navigation controls
|
||||
- Integrated with search functionality
|
||||
- Maintains proper track indices for playback
|
||||
|
||||
* Features Implemented
|
||||
|
||||
** Pagination Controls
|
||||
- First page button (« First)
|
||||
- Previous page button (‹ Prev)
|
||||
- Current page indicator (Page X of Y)
|
||||
- Next page button (Next ›)
|
||||
- Last page button (Last »)
|
||||
- Total track count display
|
||||
|
||||
** Configurable Page Size
|
||||
Admin dashboard options:
|
||||
- 10 tracks per page
|
||||
- 20 tracks per page (default)
|
||||
- 50 tracks per page
|
||||
- 100 tracks per page
|
||||
|
||||
Web player options:
|
||||
- 10 tracks per page
|
||||
- 20 tracks per page (default)
|
||||
- 50 tracks per page
|
||||
|
||||
** Smart Pagination
|
||||
- Only shows controls when needed (>1 page)
|
||||
- Maintains state during search/filter
|
||||
- Resets to page 1 on new search
|
||||
- Preserves page on sort operations
|
||||
|
||||
* Technical Implementation
|
||||
|
||||
** Admin Dashboard (admin.chtml)
|
||||
|
||||
*** Pagination Variables
|
||||
#+BEGIN_SRC javascript
|
||||
let currentPage = 1;
|
||||
let tracksPerPage = 20;
|
||||
let filteredTracks = [];
|
||||
#+END_SRC
|
||||
|
||||
*** Rendering Function
|
||||
#+BEGIN_SRC javascript
|
||||
function renderPage() {
|
||||
const totalPages = Math.ceil(filteredTracks.length / tracksPerPage);
|
||||
const startIndex = (currentPage - 1) * tracksPerPage;
|
||||
const endIndex = startIndex + tracksPerPage;
|
||||
const tracksToShow = filteredTracks.slice(startIndex, endIndex);
|
||||
|
||||
// Render tracks for current page
|
||||
const tracksHtml = tracksToShow.map((track, pageIndex) => {
|
||||
const actualIndex = startIndex + pageIndex;
|
||||
return `<div class="track-item">...</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = tracksHtml;
|
||||
|
||||
// Update pagination info
|
||||
document.getElementById('page-info').textContent =
|
||||
`Page ${currentPage} of ${totalPages} (${filteredTracks.length} tracks)`;
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
*** Navigation Functions
|
||||
#+BEGIN_SRC javascript
|
||||
function goToPage(page) {
|
||||
const totalPages = Math.ceil(filteredTracks.length / tracksPerPage);
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
renderPage();
|
||||
}
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
renderPage();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
const totalPages = Math.ceil(filteredTracks.length / tracksPerPage);
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
renderPage();
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** Web Player (player.chtml)
|
||||
|
||||
*** Track Index Management
|
||||
Critical fix for pagination with playback:
|
||||
#+BEGIN_SRC javascript
|
||||
const tracksHtml = tracksToShow.map((track, pageIndex) => {
|
||||
// Find the actual index in the full tracks array
|
||||
const actualIndex = tracks.findIndex(t => t.id === track.id);
|
||||
return `
|
||||
<button onclick="playTrack(${actualIndex})">▶️</button>
|
||||
<button onclick="addToQueue(${actualIndex})">➕</button>
|
||||
`;
|
||||
}).join('');
|
||||
#+END_SRC
|
||||
|
||||
This ensures correct track playback even when viewing paginated/filtered results.
|
||||
|
||||
* UI Components
|
||||
|
||||
** Pagination Controls HTML
|
||||
#+BEGIN_SRC html
|
||||
<div id="pagination-controls" style="display: none; margin-top: 20px; text-align: center;">
|
||||
<button onclick="goToPage(1)" class="btn btn-secondary">« First</button>
|
||||
<button onclick="previousPage()" class="btn btn-secondary">‹ Prev</button>
|
||||
<span id="page-info" style="margin: 0 15px; font-weight: bold;">Page 1 of 1</span>
|
||||
<button onclick="nextPage()" class="btn btn-secondary">Next ›</button>
|
||||
<button onclick="goToLastPage()" class="btn btn-secondary">Last »</button>
|
||||
</div>
|
||||
#+END_SRC
|
||||
|
||||
** Page Size Selector
|
||||
#+BEGIN_SRC html
|
||||
<select id="tracks-per-page" onchange="changeTracksPerPage()">
|
||||
<option value="10">10 per page</option>
|
||||
<option value="20" selected>20 per page</option>
|
||||
<option value="50">50 per page</option>
|
||||
<option value="100">100 per page</option>
|
||||
</select>
|
||||
#+END_SRC
|
||||
|
||||
* Integration
|
||||
|
||||
** With Search Functionality
|
||||
- Search filters tracks
|
||||
- Pagination updates automatically
|
||||
- Resets to page 1 on new search
|
||||
- Shows filtered track count
|
||||
|
||||
** With Sort Functionality
|
||||
- Sort maintains current page when possible
|
||||
- Updates pagination if page becomes invalid
|
||||
- Preserves user's position in list
|
||||
|
||||
** With Track Actions
|
||||
- Play button uses correct track index
|
||||
- Add to queue uses correct track index
|
||||
- Actions work across all pages
|
||||
|
||||
* Performance
|
||||
|
||||
** Benefits
|
||||
- Reduces DOM elements (only renders visible tracks)
|
||||
- Faster page load (20 tracks vs 64+)
|
||||
- Smoother scrolling
|
||||
- Better mobile experience
|
||||
|
||||
** Metrics (64 tracks)
|
||||
- Without pagination: 64 DOM elements
|
||||
- With pagination (20/page): 20 DOM elements (68% reduction)
|
||||
- Page navigation: <50ms
|
||||
- Search with pagination: <100ms
|
||||
|
||||
* Testing Results
|
||||
|
||||
** Admin Dashboard
|
||||
- ✅ 64 tracks paginated successfully
|
||||
- ✅ 4 pages at 20 tracks/page
|
||||
- ✅ All navigation buttons working
|
||||
- ✅ Page size changes work correctly
|
||||
- ✅ Search maintains pagination
|
||||
|
||||
** Web Player
|
||||
- ✅ Track library paginated
|
||||
- ✅ Play button works on all pages
|
||||
- ✅ Add to queue works on all pages
|
||||
- ✅ Search resets to page 1
|
||||
- ✅ Correct track indices maintained
|
||||
|
||||
* Files Modified
|
||||
|
||||
- =template/admin.chtml= - Admin pagination implementation
|
||||
- =template/player.chtml= - Player pagination implementation
|
||||
- =asteroid.lisp= - No backend changes needed (client-side pagination)
|
||||
|
||||
* Status: ✅ COMPLETE
|
||||
|
||||
Track pagination fully implemented and tested in both admin dashboard and web player. Handles 64+ tracks efficiently with excellent UX.
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
#+TITLE: User Management System - Complete
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
Complete user management system with dedicated admin interface, user creation, role management, and comprehensive API endpoints.
|
||||
|
||||
* What Was Completed
|
||||
|
||||
** User Management Page
|
||||
- Created dedicated =/admin/users= route
|
||||
- Separate page from main admin dashboard
|
||||
- Clean, organized interface for user administration
|
||||
|
||||
** Features Implemented
|
||||
|
||||
*** User Creation
|
||||
- Inline user creation form
|
||||
- Fields: username, email, password, role
|
||||
- Real-time validation
|
||||
- Success/error messaging
|
||||
|
||||
*** User Display
|
||||
- List all users with key information
|
||||
- Shows: username, email, role, status, creation date
|
||||
- Clean table layout with proper formatting
|
||||
|
||||
*** User Statistics
|
||||
- Total user count
|
||||
- Active/inactive breakdown
|
||||
- Role distribution
|
||||
|
||||
*** Role Management
|
||||
- Listener role (default)
|
||||
- DJ role (content creators)
|
||||
- Admin role (full access)
|
||||
|
||||
*** User Actions
|
||||
- Activate/deactivate users
|
||||
- Role assignment
|
||||
- User deletion (future enhancement)
|
||||
|
||||
** API Endpoints
|
||||
|
||||
*** GET /api/users
|
||||
Returns all users in the system
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"users": [
|
||||
{
|
||||
"id": 2,
|
||||
"username": "admin",
|
||||
"email": "admin@asteroid.radio",
|
||||
"role": "admin",
|
||||
"active": true,
|
||||
"created-date": 1759214069
|
||||
}
|
||||
]
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
*** GET /api/users/stats
|
||||
Returns user statistics
|
||||
#+BEGIN_SRC json
|
||||
{
|
||||
"status": "success",
|
||||
"total-users": 6,
|
||||
"active-users": 6,
|
||||
"roles": {
|
||||
"admin": 2,
|
||||
"listener": 4
|
||||
}
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
*** POST /api/users/create
|
||||
Creates a new user (requires admin authentication)
|
||||
#+BEGIN_SRC
|
||||
POST /asteroid/api/users/create
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
username=newuser&email=user@example.com&password=pass123&role=listener
|
||||
#+END_SRC
|
||||
|
||||
** Files Created/Modified
|
||||
|
||||
*** New Files
|
||||
- =template/users.chtml= - User management template
|
||||
- =test-user-api.sh= - API testing script
|
||||
|
||||
*** Modified Files
|
||||
- =asteroid.lisp= - Added user management routes
|
||||
- =auth-routes.lisp= - Enhanced authentication
|
||||
- =user-management.lisp= - Core user functions
|
||||
|
||||
* Technical Implementation
|
||||
|
||||
** Authentication & Authorization
|
||||
- Requires admin role for user management
|
||||
- Session-based authentication
|
||||
- Role-based access control (RBAC)
|
||||
|
||||
** Database Schema
|
||||
Users stored in USERS collection with fields:
|
||||
- =_id= - Unique identifier
|
||||
- =username= - Unique username
|
||||
- =email= - Email address
|
||||
- =password-hash= - Bcrypt hashed password
|
||||
- =role= - User role (listener/DJ/admin)
|
||||
- =active= - Active status (boolean)
|
||||
- =created-date= - Unix timestamp
|
||||
- =last-login= - Unix timestamp
|
||||
|
||||
** Security Features
|
||||
- Password hashing with bcrypt
|
||||
- Session management
|
||||
- CSRF protection (via Radiance)
|
||||
- Role-based access control
|
||||
|
||||
* Testing
|
||||
|
||||
** API Testing Script
|
||||
Created =test-user-api.sh= for comprehensive testing:
|
||||
#+BEGIN_SRC bash
|
||||
# Test user statistics
|
||||
curl -s http://localhost:8080/asteroid/api/users/stats | jq .
|
||||
|
||||
# Test user creation (with authentication)
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/asteroid/api/users/create \
|
||||
-d "username=testuser" \
|
||||
-d "email=test@example.com" \
|
||||
-d "password=testpass123" \
|
||||
-d "role=listener" | jq .
|
||||
#+END_SRC
|
||||
|
||||
** Test Results
|
||||
- ✅ All API endpoints working
|
||||
- ✅ User creation successful
|
||||
- ✅ Authentication working
|
||||
- ✅ Role assignment working
|
||||
- ✅ 6 users created and tested
|
||||
|
||||
* Usage
|
||||
|
||||
** Creating a User
|
||||
1. Navigate to =/asteroid/admin/users=
|
||||
2. Fill in the user creation form
|
||||
3. Select appropriate role
|
||||
4. Click "Create User"
|
||||
5. User appears in the list immediately
|
||||
|
||||
** Managing Users
|
||||
1. View all users in the table
|
||||
2. See user details (email, role, status)
|
||||
3. Track creation dates
|
||||
4. Monitor active/inactive status
|
||||
|
||||
* Integration
|
||||
|
||||
** With Admin Dashboard
|
||||
- Link from main admin dashboard
|
||||
- Consistent styling and navigation
|
||||
- Integrated authentication
|
||||
|
||||
** With Authentication System
|
||||
- Uses existing auth-routes.lisp
|
||||
- Leverages session management
|
||||
- Integrates with role system
|
||||
|
||||
* Future Enhancements (Requires PostgreSQL)
|
||||
- User editing
|
||||
- Password reset
|
||||
- Email verification
|
||||
- User activity logs
|
||||
- Advanced permissions
|
||||
|
||||
* Status: ✅ COMPLETE
|
||||
|
||||
User management system fully functional and production-ready. All core features implemented and tested.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
(in-package :asteroid)
|
||||
|
||||
(defun icecast-now-playing (icecast-base-url)
|
||||
(let* ((icecast-url (format nil "~a/admin/stats.xml" icecast-base-url))
|
||||
(response (drakma:http-request icecast-url
|
||||
:want-stream nil
|
||||
:basic-authorization '("admin" "asteroid_admin_2024"))))
|
||||
(when response
|
||||
(let ((xml-string (if (stringp response)
|
||||
response
|
||||
(babel:octets-to-string response :encoding :utf-8))))
|
||||
;; Simple XML parsing to extract source information
|
||||
;; Look for <source mount="/asteroid.mp3"> sections and extract title, listeners, etc.
|
||||
(multiple-value-bind (match-start match-end)
|
||||
(cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string)
|
||||
|
||||
(if match-start
|
||||
(let* ((source-section (subseq xml-string match-start
|
||||
(or (cl-ppcre:scan "</source>" xml-string :start match-start)
|
||||
(length xml-string))))
|
||||
(titlep (cl-ppcre:all-matches "<title>" source-section))
|
||||
(listenersp (cl-ppcre:all-matches "<listeners>" source-section))
|
||||
(title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown"))
|
||||
(listeners (if listenersp (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" source-section "\\1") "0")))
|
||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
(:title . ,title)
|
||||
(:listeners . ,(parse-integer listeners :junk-allowed t))))
|
||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
(:title . "Unknown")
|
||||
(:listeners . "Unknown"))))))))
|
||||
|
||||
(define-api asteroid/partial/now-playing () ()
|
||||
"Get Partial HTML with live status from Icecast server"
|
||||
(handler-case
|
||||
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
|
||||
(if now-playing-stats
|
||||
(progn
|
||||
;; TODO: it should be able to define a custom api-output for this
|
||||
;; (api-output <clip-parser> :format "html"))
|
||||
(setf (header "Content-Type") "text/html")
|
||||
(clip:process-to-string
|
||||
(load-template "partial/now-playing")
|
||||
:stats now-playing-stats))
|
||||
(progn
|
||||
(setf (header "Content-Type") "text/html")
|
||||
(clip:process-to-string
|
||||
(load-template "partial/now-playing")
|
||||
:connection-error t
|
||||
:stats nil))))
|
||||
(error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error loading profile: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
(define-api asteroid/partial/now-playing-inline () ()
|
||||
"Get inline text with now playing info (for admin dashboard and widgets)"
|
||||
(handler-case
|
||||
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
|
||||
(if now-playing-stats
|
||||
(progn
|
||||
(setf (header "Content-Type") "text/plain")
|
||||
(cdr (assoc :title now-playing-stats)))
|
||||
(progn
|
||||
(setf (header "Content-Type") "text/plain")
|
||||
"Stream Offline")))
|
||||
(error (e)
|
||||
(setf (header "Content-Type") "text/plain")
|
||||
"Error loading stream info")))
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
;;;; playlist-management.lisp - Playlist Management for Asteroid Radio
|
||||
;;;; Database operations and functions for user playlists
|
||||
|
||||
(in-package :asteroid)
|
||||
|
||||
;; Playlist management functions
|
||||
|
||||
(defun create-playlist (user-id name &optional description)
|
||||
"Create a new playlist for a user"
|
||||
(unless (db:collection-exists-p "playlists")
|
||||
(error "Playlists collection does not exist in database"))
|
||||
|
||||
(let ((playlist-data `(("user-id" ,user-id)
|
||||
("name" ,name)
|
||||
("description" ,(or description ""))
|
||||
("track-ids" "") ; Empty string for text field
|
||||
("created-date" ,(local-time:timestamp-to-unix (local-time:now))))))
|
||||
(format t "Creating playlist with user-id: ~a (type: ~a)~%" user-id (type-of user-id))
|
||||
(format t "Playlist data: ~a~%" playlist-data)
|
||||
(db:insert "playlists" playlist-data)
|
||||
t))
|
||||
|
||||
(defun get-user-playlists (user-id)
|
||||
"Get all playlists for a user"
|
||||
(format t "Querying playlists with user-id: ~a (type: ~a)~%" user-id (type-of user-id))
|
||||
(let ((all-playlists (db:select "playlists" (db:query :all))))
|
||||
(format t "Total playlists in database: ~a~%" (length all-playlists))
|
||||
(when (> (length all-playlists) 0)
|
||||
(let ((first-playlist (first all-playlists)))
|
||||
(format t "First playlist user-id: ~a (type: ~a)~%"
|
||||
(gethash "user-id" first-playlist)
|
||||
(type-of (gethash "user-id" first-playlist)))))
|
||||
;; Filter manually since DB stores user-id as a list (2) instead of 2
|
||||
(remove-if-not (lambda (playlist)
|
||||
(let ((stored-user-id (gethash "user-id" playlist)))
|
||||
(or (equal stored-user-id user-id)
|
||||
(and (listp stored-user-id)
|
||||
(equal (first stored-user-id) user-id)))))
|
||||
all-playlists)))
|
||||
|
||||
(defun get-playlist-by-id (playlist-id)
|
||||
"Get a specific playlist by ID"
|
||||
(format t "get-playlist-by-id called with: ~a (type: ~a)~%" playlist-id (type-of playlist-id))
|
||||
;; Try direct query first
|
||||
(let ((playlists (db:select "playlists" (db:query (:= "_id" playlist-id)))))
|
||||
(if (> (length playlists) 0)
|
||||
(progn
|
||||
(format t "Found via direct query~%")
|
||||
(first playlists))
|
||||
;; If not found, search manually (ID might be stored as list)
|
||||
(let ((all-playlists (db:select "playlists" (db:query :all))))
|
||||
(format t "Searching through ~a playlists manually~%" (length all-playlists))
|
||||
(find-if (lambda (playlist)
|
||||
(let ((stored-id (gethash "_id" playlist)))
|
||||
(format t "Checking playlist _id: ~a (type: ~a)~%" stored-id (type-of stored-id))
|
||||
(or (equal stored-id playlist-id)
|
||||
(and (listp stored-id) (equal (first stored-id) playlist-id)))))
|
||||
all-playlists)))))
|
||||
|
||||
(defun add-track-to-playlist (playlist-id track-id)
|
||||
"Add a track to a playlist"
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when playlist
|
||||
(let* ((current-track-ids-raw (gethash "track-ids" playlist))
|
||||
;; Handle database storing as list - extract string
|
||||
(current-track-ids (if (listp current-track-ids-raw)
|
||||
(first current-track-ids-raw)
|
||||
current-track-ids-raw))
|
||||
;; Parse comma-separated string into list
|
||||
(tracks-list (if (and current-track-ids
|
||||
(stringp current-track-ids)
|
||||
(not (string= current-track-ids "")))
|
||||
(mapcar #'parse-integer
|
||||
(cl-ppcre:split "," current-track-ids))
|
||||
nil))
|
||||
(new-tracks (append tracks-list (list track-id)))
|
||||
;; Convert back to comma-separated string
|
||||
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
|
||||
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
|
||||
(format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw))
|
||||
(format t "Current track-ids: ~a~%" current-track-ids)
|
||||
(format t "Tracks list: ~a~%" tracks-list)
|
||||
(format t "New tracks: ~a~%" new-tracks)
|
||||
(format t "Track IDs string: ~a~%" track-ids-str)
|
||||
;; Update using track-ids field (defined in schema)
|
||||
(db:update "playlists"
|
||||
(db:query (:= "_id" playlist-id))
|
||||
`(("track-ids" ,track-ids-str)))
|
||||
(format t "Update complete~%")
|
||||
t))))
|
||||
|
||||
(defun remove-track-from-playlist (playlist-id track-id)
|
||||
"Remove a track from a playlist"
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when playlist
|
||||
(let* ((current-track-ids-raw (gethash "track-ids" playlist))
|
||||
;; Handle database storing as list - extract string
|
||||
(current-track-ids (if (listp current-track-ids-raw)
|
||||
(first current-track-ids-raw)
|
||||
current-track-ids-raw))
|
||||
;; Parse comma-separated string into list
|
||||
(tracks-list (if (and current-track-ids
|
||||
(stringp current-track-ids)
|
||||
(not (string= current-track-ids "")))
|
||||
(mapcar #'parse-integer
|
||||
(cl-ppcre:split "," current-track-ids))
|
||||
nil))
|
||||
(new-tracks (remove track-id tracks-list :test #'equal))
|
||||
;; Convert back to comma-separated string
|
||||
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
|
||||
(db:update "playlists"
|
||||
(db:query (:= "_id" playlist-id))
|
||||
`(("track-ids" ,track-ids-str)))
|
||||
t))))
|
||||
|
||||
(defun delete-playlist (playlist-id)
|
||||
"Delete a playlist"
|
||||
(db:remove "playlists" (db:query (:= "_id" playlist-id)))
|
||||
t)
|
||||
|
||||
(defun ensure-playlists-collection ()
|
||||
"Ensure playlists collection exists in database"
|
||||
(unless (db:collection-exists-p "playlists")
|
||||
(format t "Creating playlists collection...~%")
|
||||
(db:create "playlists"))
|
||||
|
||||
;; Debug: Print the actual structure
|
||||
(format t "~%=== PLAYLISTS COLLECTION STRUCTURE ===~%")
|
||||
(format t "Structure: ~a~%~%" (db:structure "playlists"))
|
||||
|
||||
;; Debug: Check existing playlists
|
||||
(let ((playlists (db:select "playlists" (db:query :all))))
|
||||
(when playlists
|
||||
(format t "Sample playlist fields: ~{~a~^, ~}~%~%"
|
||||
(alexandria:hash-table-keys (first playlists))))))
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Helper script to run all three stream format tests
|
||||
# Usage: ./run-all-tests.sh
|
||||
|
||||
echo "=== Asteroid Comprehensive Performance Testing Suite ==="
|
||||
echo ""
|
||||
echo "This will run three 15-minute tests:"
|
||||
echo "1. AAC 96kbps stream"
|
||||
echo "2. MP3 128kbps stream"
|
||||
echo "3. MP3 64kbps stream"
|
||||
echo ""
|
||||
echo "Each test will:"
|
||||
echo "- Start Docker containers (Icecast2 + Liquidsoap)"
|
||||
echo "- Start Asteroid web application"
|
||||
echo "- Monitor performance for 15 minutes"
|
||||
echo "- Generate light web traffic"
|
||||
echo "- Save detailed logs and CSV data"
|
||||
echo ""
|
||||
|
||||
read -p "Press Enter to start the test suite, or Ctrl+C to cancel..."
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo ""
|
||||
echo "=== TEST 1/3: AAC 96kbps Stream ==="
|
||||
echo "Starting AAC test..."
|
||||
"$SCRIPT_DIR/comprehensive-performance-test.sh" aac
|
||||
|
||||
echo ""
|
||||
echo "AAC test completed. Please switch to AAC stream format in Liquidsoap if needed."
|
||||
read -p "Press Enter when ready for MP3 High Quality test..."
|
||||
|
||||
echo ""
|
||||
echo "=== TEST 2/3: MP3 128kbps Stream ==="
|
||||
echo "Starting MP3 High Quality test..."
|
||||
"$SCRIPT_DIR/comprehensive-performance-test.sh" mp3-high
|
||||
|
||||
echo ""
|
||||
echo "MP3 High test completed. Please switch to MP3 Low Quality stream format if needed."
|
||||
read -p "Press Enter when ready for MP3 Low Quality test..."
|
||||
|
||||
echo ""
|
||||
echo "=== TEST 3/3: MP3 64kbps Stream ==="
|
||||
echo "Starting MP3 Low Quality test..."
|
||||
"$SCRIPT_DIR/comprehensive-performance-test.sh" mp3-low
|
||||
|
||||
echo ""
|
||||
echo "=== ALL TESTS COMPLETED ==="
|
||||
echo ""
|
||||
echo "Results saved in: $SCRIPT_DIR/performance-logs/"
|
||||
echo ""
|
||||
echo "Log files created:"
|
||||
ls -la "$SCRIPT_DIR/performance-logs/" | grep "$(date +%Y%m%d)"
|
||||
|
||||
echo ""
|
||||
echo "To analyze results, check the CSV files for detailed performance data."
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
;;;; Setup script for Asteroid Radio Radiance environment
|
||||
;;;; This creates the necessary symbolic links for the custom environment
|
||||
|
||||
(defun setup-asteroid-environment ()
|
||||
"Set up the asteroid Radiance environment with symbolic links to project config"
|
||||
(let* ((project-root (asdf:system-source-directory :asteroid))
|
||||
(config-dir (merge-pathnames "config/" project-root))
|
||||
(radiance-env-dir (merge-pathnames ".config/radiance/asteroid/"
|
||||
(user-homedir-pathname))))
|
||||
|
||||
;; Ensure the radiance environment directory exists
|
||||
(ensure-directories-exist radiance-env-dir)
|
||||
|
||||
;; Create symbolic links for each config file
|
||||
(dolist (config-file '("radiance-core.conf.lisp"
|
||||
"i-lambdalite.conf.lisp"
|
||||
"simple-auth.conf.lisp"
|
||||
"simple-sessions.conf.lisp"
|
||||
"i-hunchentoot.conf.lisp"))
|
||||
(let ((source (merge-pathnames config-file config-dir))
|
||||
(target (merge-pathnames config-file radiance-env-dir)))
|
||||
(when (probe-file target)
|
||||
(delete-file target))
|
||||
(when (probe-file source)
|
||||
#+unix
|
||||
(sb-posix:symlink (namestring source) (namestring target))
|
||||
#-unix
|
||||
(progn
|
||||
(format t "Warning: Symbolic links not supported on this platform~%")
|
||||
(format t "Please manually copy ~a to ~a~%" source target)))))
|
||||
|
||||
(format t "Asteroid environment setup complete!~%")
|
||||
(format t "Config directory: ~a~%" config-dir)
|
||||
(format t "Radiance environment: ~a~%" radiance-env-dir)))
|
||||
|
||||
;; Auto-setup when loaded
|
||||
(setup-asteroid-environment)
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple Asteroid Radio Performance Analysis
|
||||
Uses only matplotlib (no seaborn dependency)
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import glob
|
||||
import os
|
||||
from datetime import datetime
|
||||
import numpy as np
|
||||
|
||||
def load_performance_data():
|
||||
"""Load all CSV performance data files"""
|
||||
csv_files = glob.glob('performance-logs/*_data_*.csv')
|
||||
data_frames = {}
|
||||
|
||||
for file in csv_files:
|
||||
# Extract test type from filename
|
||||
filename = os.path.basename(file)
|
||||
if 'aac' in filename:
|
||||
test_type = 'AAC 96kbps'
|
||||
elif 'mp3-high' in filename:
|
||||
test_type = 'MP3 128kbps'
|
||||
elif 'mp3-low' in filename:
|
||||
test_type = 'MP3 64kbps'
|
||||
else:
|
||||
test_type = filename.split('_')[1]
|
||||
|
||||
try:
|
||||
df = pd.read_csv(file)
|
||||
df['test_type'] = test_type
|
||||
data_frames[test_type] = df
|
||||
print(f"✅ Loaded {len(df)} records from {test_type} test")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading {file}: {e}")
|
||||
|
||||
return data_frames
|
||||
|
||||
def create_simple_charts(data_frames):
|
||||
"""Create simple performance charts"""
|
||||
|
||||
# Create figure with subplots
|
||||
fig, axes = plt.subplots(2, 2, figsize=(15, 10))
|
||||
fig.suptitle('Asteroid Radio Performance Analysis', fontsize=14)
|
||||
|
||||
colors = ['#ff6b6b', '#4ecdc4', '#45b7d1']
|
||||
|
||||
# 1. CPU Usage Over Time
|
||||
ax1 = axes[0, 0]
|
||||
for i, (test_type, df) in enumerate(data_frames.items()):
|
||||
if 'cpu_percent' in df.columns:
|
||||
ax1.plot(range(len(df)), df['cpu_percent'],
|
||||
label=test_type, color=colors[i % len(colors)], linewidth=2)
|
||||
ax1.set_title('CPU Usage Over Time')
|
||||
ax1.set_xlabel('Time (samples)')
|
||||
ax1.set_ylabel('CPU %')
|
||||
ax1.legend()
|
||||
ax1.grid(True, alpha=0.3)
|
||||
|
||||
# 2. Memory Usage Over Time
|
||||
ax2 = axes[0, 1]
|
||||
for i, (test_type, df) in enumerate(data_frames.items()):
|
||||
if 'memory_mb' in df.columns:
|
||||
ax2.plot(range(len(df)), df['memory_mb'],
|
||||
label=test_type, color=colors[i % len(colors)], linewidth=2)
|
||||
ax2.set_title('Memory Usage Over Time')
|
||||
ax2.set_xlabel('Time (samples)')
|
||||
ax2.set_ylabel('Memory (MB)')
|
||||
ax2.legend()
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
# 3. Average Performance Comparison
|
||||
ax3 = axes[1, 0]
|
||||
test_names = []
|
||||
cpu_avgs = []
|
||||
mem_avgs = []
|
||||
|
||||
for test_type, df in data_frames.items():
|
||||
test_names.append(test_type.replace(' ', '\n'))
|
||||
cpu_avgs.append(df['cpu_percent'].mean() if 'cpu_percent' in df.columns else 0)
|
||||
mem_avgs.append(df['memory_mb'].mean() if 'memory_mb' in df.columns else 0)
|
||||
|
||||
x = np.arange(len(test_names))
|
||||
width = 0.35
|
||||
|
||||
ax3.bar(x - width/2, cpu_avgs, width, label='CPU %', color=colors[0], alpha=0.8)
|
||||
ax3_twin = ax3.twinx()
|
||||
ax3_twin.bar(x + width/2, mem_avgs, width, label='Memory MB', color=colors[1], alpha=0.8)
|
||||
|
||||
ax3.set_title('Average Resource Usage')
|
||||
ax3.set_xlabel('Stream Type')
|
||||
ax3.set_ylabel('CPU %', color=colors[0])
|
||||
ax3_twin.set_ylabel('Memory (MB)', color=colors[1])
|
||||
ax3.set_xticks(x)
|
||||
ax3.set_xticklabels(test_names)
|
||||
|
||||
# 4. Response Time Summary
|
||||
ax4 = axes[1, 1]
|
||||
response_summary = {}
|
||||
|
||||
for test_type, df in data_frames.items():
|
||||
stream_resp = df['stream_response_ms'].mean() if 'stream_response_ms' in df.columns else 0
|
||||
web_resp = df['web_response_ms'].mean() if 'web_response_ms' in df.columns else 0
|
||||
response_summary[test_type] = {'Stream': stream_resp, 'Web': web_resp}
|
||||
|
||||
if response_summary:
|
||||
test_types = list(response_summary.keys())
|
||||
stream_times = [response_summary[t]['Stream'] for t in test_types]
|
||||
web_times = [response_summary[t]['Web'] for t in test_types]
|
||||
|
||||
x = np.arange(len(test_types))
|
||||
ax4.bar(x - width/2, stream_times, width, label='Stream Response', color=colors[0], alpha=0.8)
|
||||
ax4.bar(x + width/2, web_times, width, label='Web Response', color=colors[1], alpha=0.8)
|
||||
|
||||
ax4.set_title('Average Response Times')
|
||||
ax4.set_xlabel('Stream Type')
|
||||
ax4.set_ylabel('Response Time (ms)')
|
||||
ax4.set_xticks(x)
|
||||
ax4.set_xticklabels([t.replace(' ', '\n') for t in test_types])
|
||||
ax4.legend()
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig('performance-logs/asteroid_performance_charts.png', dpi=300, bbox_inches='tight')
|
||||
print("📊 Charts saved as: performance-logs/asteroid_performance_charts.png")
|
||||
|
||||
return fig
|
||||
|
||||
def generate_text_report(data_frames):
|
||||
"""Generate simple text report"""
|
||||
|
||||
report = []
|
||||
report.append("🎵 ASTEROID RADIO PERFORMANCE REPORT")
|
||||
report.append("=" * 45)
|
||||
report.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
report.append("")
|
||||
|
||||
for test_type, df in data_frames.items():
|
||||
report.append(f"📡 {test_type}:")
|
||||
report.append("-" * 25)
|
||||
|
||||
if 'cpu_percent' in df.columns:
|
||||
cpu_mean = df['cpu_percent'].mean()
|
||||
cpu_max = df['cpu_percent'].max()
|
||||
report.append(f" CPU: {cpu_mean:.1f}% avg, {cpu_max:.1f}% peak")
|
||||
|
||||
if 'memory_mb' in df.columns:
|
||||
mem_mean = df['memory_mb'].mean()
|
||||
mem_max = df['memory_mb'].max()
|
||||
report.append(f" Memory: {mem_mean:.1f} MB avg, {mem_max:.1f} MB peak")
|
||||
|
||||
if 'stream_response_ms' in df.columns:
|
||||
stream_resp = df['stream_response_ms'].dropna()
|
||||
if len(stream_resp) > 0:
|
||||
report.append(f" Stream Response: {stream_resp.mean():.1f} ms avg")
|
||||
|
||||
if 'web_response_ms' in df.columns:
|
||||
web_resp = df['web_response_ms'].dropna()
|
||||
if len(web_resp) > 0:
|
||||
report.append(f" Web Response: {web_resp.mean():.1f} ms avg")
|
||||
|
||||
report.append(f" Test Duration: {len(df)} samples")
|
||||
report.append("")
|
||||
|
||||
# Summary
|
||||
report.append("📊 SUMMARY:")
|
||||
report.append("-" * 15)
|
||||
|
||||
# Find most efficient stream
|
||||
cpu_usage = {}
|
||||
for test_type, df in data_frames.items():
|
||||
if 'cpu_percent' in df.columns:
|
||||
cpu_usage[test_type] = df['cpu_percent'].mean()
|
||||
|
||||
if cpu_usage:
|
||||
best = min(cpu_usage, key=cpu_usage.get)
|
||||
worst = max(cpu_usage, key=cpu_usage.get)
|
||||
report.append(f" Most efficient: {best} ({cpu_usage[best]:.1f}% CPU)")
|
||||
report.append(f" Most intensive: {worst} ({cpu_usage[worst]:.1f}% CPU)")
|
||||
|
||||
total_samples = sum(len(df) for df in data_frames.values())
|
||||
report.append(f" Total test samples: {total_samples}")
|
||||
report.append(f" Stream formats tested: {len(data_frames)}")
|
||||
|
||||
# Save report
|
||||
with open('performance-logs/asteroid_simple_report.txt', 'w') as f:
|
||||
f.write('\n'.join(report))
|
||||
|
||||
print("📄 Report saved as: performance-logs/asteroid_simple_report.txt")
|
||||
return '\n'.join(report)
|
||||
|
||||
def main():
|
||||
print("🎵 Asteroid Radio Performance Analyzer (Simple)")
|
||||
print("=" * 45)
|
||||
|
||||
# Load data
|
||||
data_frames = load_performance_data()
|
||||
|
||||
if not data_frames:
|
||||
print("❌ No performance data found!")
|
||||
return
|
||||
|
||||
# Create charts
|
||||
print("\n📊 Creating performance charts...")
|
||||
create_simple_charts(data_frames)
|
||||
|
||||
# Generate report
|
||||
print("\n📄 Generating performance report...")
|
||||
report = generate_text_report(data_frames)
|
||||
|
||||
print("\n✅ Analysis complete!")
|
||||
print("\nFiles created:")
|
||||
print(" 📊 performance-logs/asteroid_performance_charts.png")
|
||||
print(" 📄 performance-logs/asteroid_simple_report.txt")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap");
|
||||
|
||||
body{
|
||||
font-family: VT323, monospace;
|
||||
font-weight: 400;
|
||||
|
|
@ -6,6 +8,7 @@ body{
|
|||
color: #00ffff;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body .container{
|
||||
|
|
@ -40,20 +43,60 @@ body .panel{
|
|||
|
||||
body .nav{
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body .nav a{
|
||||
color: #00ffff;
|
||||
text-decoration: none;
|
||||
margin: 0 15px;
|
||||
margin: 0;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #2a3441;
|
||||
background: #1a2332;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
border-sizing: border-box;
|
||||
letter-spacing: 0.08rem;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
body .nav a :hover{
|
||||
body .nav a:first-child{
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
body .nav a:hover{
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 5px;
|
||||
background: #2a3441;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
body .nav .btn-logout{
|
||||
background: #2a3441;
|
||||
border-color: #3a4551;
|
||||
color: #ff9999;
|
||||
}
|
||||
|
||||
body .nav .btn-logout:hover{
|
||||
background: #3a4551;
|
||||
border-color: #4a5561;
|
||||
color: #ffaaaa;
|
||||
}
|
||||
|
||||
body [data-show-if-logged-in]{
|
||||
display: none;
|
||||
}
|
||||
|
||||
body [data-show-if-logged-out]{
|
||||
display: none;
|
||||
}
|
||||
|
||||
body [data-show-if-admin]{
|
||||
display: none;
|
||||
}
|
||||
|
||||
body .controls{
|
||||
|
|
@ -69,7 +112,7 @@ body .controls button{
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
body .controls button :hover{
|
||||
body .controls button:hover{
|
||||
background: #2a3441;
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +125,7 @@ body button{
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
body button :hover{
|
||||
body button:hover{
|
||||
background: #3a4551;
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +136,7 @@ body .now-playing{
|
|||
margin: 20px 0;
|
||||
font-size: 1.5em;
|
||||
color: #4488ff;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body .back{
|
||||
|
|
@ -102,7 +146,7 @@ body .back{
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
body .back :hover{
|
||||
body .back:hover{
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +174,27 @@ body .player-section{
|
|||
border-radius: 5px;
|
||||
}
|
||||
|
||||
body .player-section .live-stream{
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
body .live-stream .live-stream-quality label,
|
||||
body .live-stream .live-stream-quality select{
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
body .live-stream .live-stream-quality label{
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
body .live-stream .live-stream-quality select{
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
body .track-browser{
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
|
@ -143,6 +208,12 @@ body .search-input{
|
|||
font-family: Courier New, monospace;
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body .sort-select{
|
||||
padding: 0.25rem;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
body .track-list{
|
||||
|
|
@ -165,10 +236,6 @@ body .track-item{
|
|||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
body .track-item :hover{
|
||||
background: #1a2332;
|
||||
}
|
||||
|
||||
body .track-info{
|
||||
flex: 1;
|
||||
}
|
||||
|
|
@ -276,17 +343,19 @@ body .btn{
|
|||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
body .btn :hover{
|
||||
body .btn:hover{
|
||||
background: #3a4551;
|
||||
border-color: #3a4551;
|
||||
}
|
||||
|
||||
|
||||
|
||||
body .btn .btn-primary{
|
||||
background: #0066cc;
|
||||
border-color: #0088ff;
|
||||
}
|
||||
|
||||
body .btn .btn-primary :hover{
|
||||
body .btn .btn-primary:hover{
|
||||
background: #0088ff;
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +364,7 @@ body .btn .btn-secondary{
|
|||
border-color: #2a3441;
|
||||
}
|
||||
|
||||
body .btn .btn-secondary :hover{
|
||||
body .btn .btn-secondary:hover{
|
||||
background: #666;
|
||||
}
|
||||
|
||||
|
|
@ -310,36 +379,37 @@ body .btn .btn.active{
|
|||
color: #000;
|
||||
}
|
||||
|
||||
body .btn .playlist-controls{
|
||||
body .playlist-controls{
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
body .btn .playlist-input{
|
||||
body .playlist-input{
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: #0a0a0a;
|
||||
color: #00ffff;
|
||||
border: 1px solid #2a3441;
|
||||
font-family: Courier New, monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body .btn .playlist-list{
|
||||
body .playlist-list{
|
||||
border: 1px solid #2a3441;
|
||||
background: #0a0a0a;
|
||||
min-height: 100px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
body .btn .queue-controls{
|
||||
body .queue-controls{
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body .btn .play-queue{
|
||||
body .play-queue{
|
||||
border: 1px solid #2a3441;
|
||||
background: #0a0a0a;
|
||||
min-height: 150px;
|
||||
|
|
@ -348,7 +418,7 @@ body .btn .play-queue{
|
|||
padding: 10px;
|
||||
}
|
||||
|
||||
body .btn .queue-item{
|
||||
body .queue-item{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
|
@ -357,46 +427,129 @@ body .btn .queue-item{
|
|||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
body .btn .queue-item :last-child{
|
||||
body .queue-item:last-child{
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
body .btn .empty-queue{
|
||||
body .queue-position{
|
||||
background: #00ff00;
|
||||
color: #000;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
body .queue-track-info{
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
body .queue-track-info.track-title{
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
body .queue-track-info.track-artist{
|
||||
font-size: 0.9em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body .queue-actions{
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
body .queue-list{
|
||||
border: 1px solid #2a3441;
|
||||
background: #0a0a0a;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
body .search-results{
|
||||
margin-top: 10px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
body .search-result-item{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid #2a3441;
|
||||
margin-bottom: 5px;
|
||||
background: #0a0a0a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body .search-result-item:hover{
|
||||
background: #1a1a1a;
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
body .search-result-item.track-info{
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
body .search-result-item.track-actions{
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
body .empty-state{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 30px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
body .empty-queue{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
body .btn .no-tracks{
|
||||
body .no-tracks{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
body .btn .no-playlists{
|
||||
body .no-playlists{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
body .btn .loading{
|
||||
body .loading{
|
||||
text-align: center;
|
||||
color: #4488ff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
body .btn .error{
|
||||
body .error{
|
||||
text-align: center;
|
||||
color: #ff0000;
|
||||
padding: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
body .btn .upload-section{
|
||||
body .upload-section{
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: #0a0a0a;
|
||||
|
|
@ -404,19 +557,19 @@ body .btn .upload-section{
|
|||
border-radius: 5px;
|
||||
}
|
||||
|
||||
body .btn .upload-controls{
|
||||
body .upload-controls{
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
body .btn .upload-info{
|
||||
body .upload-info{
|
||||
color: #888;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
body .btn .upload-progress{
|
||||
body .upload-progress{
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #1a2332;
|
||||
|
|
@ -424,7 +577,7 @@ body .btn .upload-progress{
|
|||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body .btn .progress-bar{
|
||||
body .progress-bar{
|
||||
height: 20px;
|
||||
background: #4488ff;
|
||||
border-radius: 3px;
|
||||
|
|
@ -436,14 +589,14 @@ body .btn .progress-bar{
|
|||
width: 0%;
|
||||
}
|
||||
|
||||
body .btn .progress-text{
|
||||
body .progress-text{
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #00ffff;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
body .btn input{
|
||||
body input{
|
||||
padding: 8px 12px;
|
||||
background: #1a2332;
|
||||
color: #00ffff;
|
||||
|
|
@ -452,7 +605,7 @@ body .btn input{
|
|||
font-family: Courier New, monospace;
|
||||
}
|
||||
|
||||
body .btn .upload-interface{
|
||||
body .upload-interface{
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #1a2332;
|
||||
|
|
@ -460,12 +613,12 @@ body .btn .upload-interface{
|
|||
border: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
body .btn .upload-interface h3{
|
||||
body .upload-interface h3{
|
||||
color: #00ffff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
body .btn .upload-interface .upload-area{
|
||||
body .upload-interface .upload-area{
|
||||
border: 2px dashed #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
|
|
@ -478,26 +631,26 @@ body .btn .upload-interface .upload-area{
|
|||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
body .btn .upload-interface .upload-area &:hover{
|
||||
border-color: #00ffff;
|
||||
}
|
||||
|
||||
body .btn .upload-interface .upload-area .upload-icon{
|
||||
body .upload-interface .upload-area .upload-icon{
|
||||
font-size: 3rem;
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
body .btn .upload-interface .upload-area p{
|
||||
body .upload-interface .upload-area p{
|
||||
color: #999;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
body .btn .upload-interface .upload-area .btn{
|
||||
body .upload-interface .upload-area .btn{
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
body .btn .auth-container{
|
||||
body .upload-interface .upload-area:hover{
|
||||
border-color: #00ffff;
|
||||
}
|
||||
|
||||
body .auth-container{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
|
@ -505,13 +658,13 @@ body .btn .auth-container{
|
|||
padding: 2rem;
|
||||
}
|
||||
|
||||
body .btn .auth-form{
|
||||
body .auth-form{
|
||||
background-color: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-width: 600px;
|
||||
-moz-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
-o-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
-webkit-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
|
|
@ -519,31 +672,31 @@ body .btn .auth-form{
|
|||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
body .btn .auth-form h2{
|
||||
body .auth-form h2{
|
||||
color: #00ffff;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
body .btn .auth-form h3{
|
||||
body .auth-form h3{
|
||||
color: #00ffff;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
body .btn .form-group{
|
||||
margin-bottom: 1rem;
|
||||
body .form-group{
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
body .btn .form-group label{
|
||||
body .form-group label{
|
||||
display: block;
|
||||
color: #ccc;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
body .btn .form-group input{
|
||||
body .form-group input{
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: #0f0f0f;
|
||||
|
|
@ -554,7 +707,7 @@ body .btn .form-group input{
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body .btn .form-group input &:focus{
|
||||
body .form-group input:focus{
|
||||
border-color: #00ffff;
|
||||
outline: none;
|
||||
-moz-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
|
||||
|
|
@ -564,13 +717,13 @@ body .btn .form-group input &:focus{
|
|||
box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
|
||||
}
|
||||
|
||||
body .btn .form-actions{
|
||||
body .form-actions{
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
body .btn .message{
|
||||
body .message{
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
|
|
@ -578,58 +731,38 @@ body .btn .message{
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
body .btn .message &.success{
|
||||
body .message.success{
|
||||
background-color: rgba(0, 255, 0, 0.1);
|
||||
border: 1px solid #00ffff;
|
||||
color: #00ffff;
|
||||
}
|
||||
|
||||
body .btn .message &.error{
|
||||
body .message.error{
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid #ff0000;
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
body .btn .auth-link{
|
||||
body .auth-link{
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
body .btn .auth-link a{
|
||||
body .auth-link a{
|
||||
color: #00ffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body .btn .auth-link a &:hover{
|
||||
body .auth-link a:hover{
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body .btn .profile-container{
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
body .btn .profile-card{
|
||||
background-color: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
body .profile-info{
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
body .btn .profile-card h2{
|
||||
color: #00ffff;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body .btn .profile-info{
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
body .btn .info-group{
|
||||
body .info-group{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
|
@ -637,20 +770,20 @@ body .btn .info-group{
|
|||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
body .btn .info-group &:last-child{
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
body .btn .info-group label{
|
||||
body .info-group label{
|
||||
color: #ccc;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
body .btn .info-group span{
|
||||
body .info-group span{
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body .btn .role-badge{
|
||||
body .info-group:last-child{
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
body .role-badge{
|
||||
background-color: #00ffff;
|
||||
color: #000;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
|
@ -659,17 +792,154 @@ body .btn .role-badge{
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
body .btn .profile-actions{
|
||||
body .profile-actions{
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body .btn .user-management{
|
||||
body .artist-stats{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
body .artist-item{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
body .artist-item:last-child{
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
body .artist-name{
|
||||
color: #e0e6ed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body .artist-plays{
|
||||
color: #8892b0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body .track-item{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
body .track-item:last-child{
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
body .track-info{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
body .track-title{
|
||||
color: #e0e6ed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body .track-artist{
|
||||
color: #8892b0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body .track-meta{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
body .track-duration{
|
||||
color: #64ffda;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
body .track-played-at{
|
||||
color: #8892b0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
body .activity-chart{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body .chart-placeholder{
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
height: 120px;
|
||||
margin: 1rem 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
body .chart-bar{
|
||||
width: 8px;
|
||||
background-color: #64ffda;
|
||||
border-radius: 2px 2px 0 0;
|
||||
margin: 0 1px;
|
||||
min-height: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
body .chart-bar:hover{
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body .chart-note{
|
||||
color: #8892b0;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
body .stat-number{
|
||||
color: #64ffda;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
}
|
||||
|
||||
body .stat-text{
|
||||
color: #e0e6ed;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
body .toast{
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 1000;
|
||||
-moz-transition: opacity 0.3s ease;
|
||||
-o-transition: opacity 0.3s ease;
|
||||
-webkit-transition: opacity 0.3s ease;
|
||||
-ms-transition: opacity 0.3s ease;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
body .user-management{
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
body .btn .users-table{
|
||||
body .users-table{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: #1a2332;
|
||||
|
|
@ -678,11 +948,11 @@ body .btn .users-table{
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
body .btn .users-table thead{
|
||||
body .users-table thead{
|
||||
background-color: #0f0f0f;
|
||||
}
|
||||
|
||||
body .btn .users-table thead th{
|
||||
body .users-table thead th{
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
color: #00ffff;
|
||||
|
|
@ -692,38 +962,38 @@ body .btn .users-table thead th{
|
|||
|
||||
|
||||
|
||||
body .btn .users-table tbody tr{
|
||||
body .users-table tbody tr{
|
||||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
body .btn .users-table tbody tr &:hover{
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
body .btn .users-table tbody tr td{
|
||||
body .users-table tbody tr td{
|
||||
padding: 1rem;
|
||||
color: #fff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
body .btn .users-table tbody .user-actions{
|
||||
body .users-table tbody tr:hover{
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
body .users-table tbody .user-actions{
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
body .btn .users-table tbody .user-actions .btn{
|
||||
body .users-table tbody .user-actions .btn{
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body .btn .user-stats{
|
||||
body .user-stats{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
body .btn .stat-card{
|
||||
body .stat-card{
|
||||
background-color: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
|
|
@ -731,15 +1001,28 @@ body .btn .stat-card{
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
body .btn .stat-card .stat-number{
|
||||
body .stat-card .stat-number{
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #00ffff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
body .btn .stat-card .stat-label{
|
||||
body .stat-card .stat-label{
|
||||
color: #ccc;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 576px){
|
||||
body .playlist-controls{
|
||||
display: block;
|
||||
}
|
||||
body .playlist-controls >*{
|
||||
width: 100%;
|
||||
}
|
||||
body .playlist-controls button{
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
;; LASS stylesheet for Asteroid Radio
|
||||
;; Hacker-themed green terminal styling
|
||||
|
||||
(body
|
||||
(:let ()
|
||||
(:import (url "https://fonts.googleapis.com/css2?family=VT323&display=swap"))
|
||||
(body
|
||||
;; :font-family "Courier New, monospace"
|
||||
:font-family "VT323, monospace"
|
||||
:font-weight 400
|
||||
|
|
@ -10,6 +12,7 @@
|
|||
:color "#00ffff"
|
||||
:margin 0
|
||||
:padding "20px"
|
||||
:box-sizing "border-box"
|
||||
|
||||
(.container
|
||||
:max-width "1200px"
|
||||
|
|
@ -38,16 +41,53 @@
|
|||
|
||||
(.nav
|
||||
:margin "20px 0"
|
||||
:display "flex"
|
||||
:gap "5px"
|
||||
:flex-wrap "wrap"
|
||||
:justify-content center
|
||||
(a
|
||||
:color "#00ffff"
|
||||
:text-decoration none
|
||||
:margin "0 15px"
|
||||
:margin "0"
|
||||
:padding "10px 20px"
|
||||
:border "1px solid #2a3441"
|
||||
:background "#1a2332"
|
||||
:display inline-block
|
||||
(:hover
|
||||
:background "#2a3441")))
|
||||
:min-width "100px"
|
||||
:text-align "center"
|
||||
:border-sizing "border-box"
|
||||
:letter-spacing "0.08rem"
|
||||
:cursor pointer
|
||||
:display inline-block)
|
||||
|
||||
((:and a :first-child)
|
||||
:margin-left "0")
|
||||
|
||||
((:and a :hover)
|
||||
:text-decoration underline
|
||||
:text-underline-offset "5px"
|
||||
:background "#2a3441"
|
||||
:color "#00ff00")
|
||||
|
||||
;; Logout button styling - subtle, not alarming
|
||||
(.btn-logout
|
||||
:background "#2a3441"
|
||||
:border-color "#3a4551"
|
||||
:color "#ff9999")
|
||||
|
||||
((:and .btn-logout :hover)
|
||||
:background "#3a4551"
|
||||
:border-color "#4a5561"
|
||||
:color "#ffaaaa"))
|
||||
|
||||
;; Hide conditional auth elements by default (JavaScript will show them)
|
||||
(|[data-show-if-logged-in]|
|
||||
:display none)
|
||||
|
||||
(|[data-show-if-logged-out]|
|
||||
:display none)
|
||||
|
||||
(|[data-show-if-admin]|
|
||||
:display none)
|
||||
|
||||
(.controls
|
||||
:margin "20px 0"
|
||||
|
|
@ -57,9 +97,9 @@
|
|||
:border "1px solid #2a3441"
|
||||
:padding "10px 20px"
|
||||
:margin "5px"
|
||||
:cursor pointer
|
||||
(:hover
|
||||
:background "#2a3441")))
|
||||
:cursor pointer)
|
||||
((:and button :hover)
|
||||
:background "#2a3441"))
|
||||
|
||||
(button
|
||||
:background "#2a3441"
|
||||
|
|
@ -67,9 +107,10 @@
|
|||
:border "1px solid #3a4551"
|
||||
:padding "10px 20px"
|
||||
:margin "5px"
|
||||
:cursor pointer
|
||||
(:hover
|
||||
:background "#3a4551"))
|
||||
:cursor pointer)
|
||||
|
||||
((:and button :hover)
|
||||
:background "#3a4551")
|
||||
|
||||
(.now-playing
|
||||
:background "#1a2332"
|
||||
|
|
@ -77,15 +118,16 @@
|
|||
:border "1px solid #2a3441"
|
||||
:margin "20px 0"
|
||||
:font-size "1.5em"
|
||||
:color "#4488ff")
|
||||
:color "#4488ff"
|
||||
:overflow auto)
|
||||
|
||||
(.back
|
||||
:color "#00ffff"
|
||||
:text-decoration none
|
||||
:margin-bottom "20px"
|
||||
:display inline-block
|
||||
(:hover
|
||||
:text-decoration underline))
|
||||
:display inline-block)
|
||||
((:and .back :hover)
|
||||
:text-decoration underline)
|
||||
|
||||
;; Player-specific styles
|
||||
(.player
|
||||
|
|
@ -106,7 +148,20 @@
|
|||
:padding "25px"
|
||||
:border "1px solid #2a3441"
|
||||
:margin "20px 0"
|
||||
:border-radius "5px")
|
||||
:border-radius "5px"
|
||||
(.live-stream
|
||||
:overflow auto) )
|
||||
|
||||
(.live-stream
|
||||
(.live-stream-quality
|
||||
|
||||
((:or label select) :margin "10px 0")
|
||||
|
||||
(label
|
||||
:margin-right "10px")
|
||||
|
||||
(select
|
||||
:padding "5px")))
|
||||
|
||||
(.track-browser
|
||||
:margin "15px 0")
|
||||
|
|
@ -119,7 +174,12 @@
|
|||
:border "1px solid #2a3441"
|
||||
:font-family "Courier New, monospace"
|
||||
:font-size "14px"
|
||||
:margin-bottom "15px")
|
||||
:margin-bottom "15px"
|
||||
:box-sizing "border-box")
|
||||
|
||||
(.sort-select
|
||||
:padding "0.25rem"
|
||||
:margin-right "10px")
|
||||
|
||||
(.track-list
|
||||
:max-height "400px"
|
||||
|
|
@ -133,9 +193,7 @@
|
|||
:align-items center
|
||||
:padding "12px 15px"
|
||||
:border-bottom "1px solid #2a3441"
|
||||
:transition "background-color 0.2s"
|
||||
(:hover
|
||||
:background "#1a2332"))
|
||||
:transition "background-color 0.2s")
|
||||
|
||||
(.track-info
|
||||
:flex 1
|
||||
|
|
@ -217,22 +275,23 @@
|
|||
:font-family "Courier New, monospace"
|
||||
:font-size "14px"
|
||||
:border-radius "3px"
|
||||
:transition "all 0.2s"
|
||||
(:hover
|
||||
:transition "all 0.2s")
|
||||
((:and .btn :hover)
|
||||
:background "#3a4551"
|
||||
:border-color "#3a4551")
|
||||
|
||||
(.btn
|
||||
(.btn-primary
|
||||
:background "#0066cc"
|
||||
:border-color "#0088ff"
|
||||
(:hover
|
||||
:background "#0088ff"))
|
||||
:border-color "#0088ff")
|
||||
((:and .btn-primary :hover)
|
||||
:background "#0088ff")
|
||||
|
||||
(.btn-secondary
|
||||
:background "#444"
|
||||
:border-color "#2a3441"
|
||||
(:hover
|
||||
:background "#666"))
|
||||
:border-color "#2a3441")
|
||||
((:and .btn-secondary :hover)
|
||||
:background "#666")
|
||||
|
||||
(.btn-sm
|
||||
:padding "4px 8px"
|
||||
|
|
@ -241,7 +300,7 @@
|
|||
(.btn.active
|
||||
:background "#4488ff"
|
||||
:border-color "#5599ff"
|
||||
:color "#000")
|
||||
:color "#000"))
|
||||
|
||||
;; Playlist and Queue styles
|
||||
(.playlist-controls
|
||||
|
|
@ -256,7 +315,8 @@
|
|||
:background "#0a0a0a"
|
||||
:color "#00ffff"
|
||||
:border "1px solid #2a3441"
|
||||
:font-family "Courier New, monospace")
|
||||
:font-family "Courier New, monospace"
|
||||
:box-sizing "border-box")
|
||||
|
||||
(.playlist-list
|
||||
:border "1px solid #2a3441"
|
||||
|
|
@ -283,10 +343,81 @@
|
|||
:align-items center
|
||||
:padding "8px 10px"
|
||||
:border-bottom "1px solid #2a3441"
|
||||
:margin-bottom "5px"
|
||||
(:last-child
|
||||
:margin-bottom "5px")
|
||||
((:and .queue-item :last-child)
|
||||
:border-bottom none
|
||||
:margin-bottom 0))
|
||||
:margin-bottom 0)
|
||||
|
||||
(.queue-position
|
||||
:background "#00ff00"
|
||||
:color "#000"
|
||||
:padding "4px 8px"
|
||||
:border-radius "3px"
|
||||
:font-weight bold
|
||||
:margin-right "10px"
|
||||
:min-width "30px"
|
||||
:text-align center
|
||||
:display inline-block)
|
||||
|
||||
(.queue-track-info
|
||||
:flex 1
|
||||
:margin-right "10px")
|
||||
|
||||
((:and .queue-track-info .track-title)
|
||||
:font-weight bold
|
||||
:margin-bottom "2px")
|
||||
|
||||
((:and .queue-track-info .track-artist)
|
||||
:font-size "0.9em"
|
||||
:color "#888")
|
||||
|
||||
(.queue-actions
|
||||
:margin-top "20px"
|
||||
:padding "15px"
|
||||
:background "#0a0a0a"
|
||||
:border "1px solid #2a3441"
|
||||
:border-radius "4px")
|
||||
|
||||
(.queue-list
|
||||
:border "1px solid #2a3441"
|
||||
:background "#0a0a0a"
|
||||
:min-height "200px"
|
||||
:max-height "400px"
|
||||
:overflow-y auto
|
||||
:padding "10px"
|
||||
:margin-bottom "20px")
|
||||
|
||||
(.search-results
|
||||
:margin-top "10px"
|
||||
:max-height "300px"
|
||||
:overflow-y auto)
|
||||
|
||||
(.search-result-item
|
||||
:display flex
|
||||
:justify-content space-between
|
||||
:align-items center
|
||||
:padding "10px"
|
||||
:border "1px solid #2a3441"
|
||||
:margin-bottom "5px"
|
||||
:background "#0a0a0a"
|
||||
:border-radius "3px")
|
||||
|
||||
((:and .search-result-item :hover)
|
||||
:background "#1a1a1a"
|
||||
:border-color "#00ff00")
|
||||
|
||||
((:and .search-result-item .track-info)
|
||||
:flex 1)
|
||||
|
||||
((:and .search-result-item .track-actions)
|
||||
:display flex
|
||||
:gap "5px")
|
||||
|
||||
(.empty-state
|
||||
:text-align center
|
||||
:color "#666"
|
||||
:padding "30px"
|
||||
:font-style italic)
|
||||
|
||||
(.empty-queue
|
||||
:text-align center
|
||||
|
|
@ -381,8 +512,6 @@
|
|||
:background-color "#0f0f0f"
|
||||
:transition "border-color 0.3s ease"
|
||||
|
||||
("&:hover" :border-color "#00ffff")
|
||||
|
||||
(.upload-icon :font-size 3rem
|
||||
:color "#666"
|
||||
:margin-bottom 1rem)
|
||||
|
|
@ -390,7 +519,10 @@
|
|||
(p :color "#999"
|
||||
:margin-bottom 1rem)
|
||||
|
||||
(.btn :margin-top 1rem)))
|
||||
(.btn :margin-top 1rem))
|
||||
|
||||
((:and .upload-area :hover)
|
||||
:border-color "#00ffff"))
|
||||
|
||||
;; Authentication Styles
|
||||
(.auth-container
|
||||
|
|
@ -406,7 +538,7 @@
|
|||
:border-radius 8px
|
||||
:padding 2rem
|
||||
:width "100%"
|
||||
:max-width 400px
|
||||
:max-width 600px
|
||||
:box-shadow "0 4px 6px rgba(0, 0, 0, 0.3)"
|
||||
|
||||
(h2 :color "#00ffff"
|
||||
|
|
@ -419,7 +551,7 @@
|
|||
:font-size 1.2rem))
|
||||
|
||||
(.form-group
|
||||
:margin-bottom 1rem
|
||||
:margin-bottom 1.5rem
|
||||
|
||||
(label :display block
|
||||
:color "#ccc"
|
||||
|
|
@ -433,11 +565,12 @@
|
|||
:border-radius 4px
|
||||
:color "#fff"
|
||||
:font-size 1rem
|
||||
:box-sizing border-box
|
||||
:box-sizing border-box)
|
||||
|
||||
("&:focus" :border-color "#00ffff"
|
||||
((:and input :focus)
|
||||
:border-color "#00ffff"
|
||||
:outline none
|
||||
:box-shadow "0 0 0 2px rgba(0, 255, 0, 0.2)")))
|
||||
:box-shadow "0 0 0 2px rgba(0, 255, 0, 0.2)"))
|
||||
|
||||
(.form-actions
|
||||
:display flex
|
||||
|
|
@ -451,11 +584,13 @@
|
|||
:text-align center
|
||||
:font-weight bold
|
||||
|
||||
("&.success" :background-color "rgba(0, 255, 0, 0.1)"
|
||||
((:parent .success)
|
||||
:background-color "rgba(0, 255, 0, 0.1)"
|
||||
:border "1px solid #00ffff"
|
||||
:color "#00ffff")
|
||||
|
||||
("&.error" :background-color "rgba(255, 0, 0, 0.1)"
|
||||
((:parent .error)
|
||||
:background-color "rgba(255, 0, 0, 0.1)"
|
||||
:border "1px solid #ff0000"
|
||||
:color "#ff0000"))
|
||||
|
||||
|
|
@ -465,27 +600,11 @@
|
|||
:color "#999"
|
||||
|
||||
(a :color "#00ffff"
|
||||
:text-decoration none
|
||||
:text-decoration none)
|
||||
|
||||
("&:hover" :text-decoration underline)))
|
||||
((:and a :hover) :text-decoration underline))
|
||||
|
||||
;; Profile Styles
|
||||
(.profile-container
|
||||
:max-width 600px
|
||||
:margin "2rem auto"
|
||||
:padding 0 1rem)
|
||||
|
||||
(.profile-card
|
||||
:background-color "#1a2332"
|
||||
:border "1px solid #2a3441"
|
||||
:border-radius 8px
|
||||
:padding 2rem
|
||||
:margin-bottom 2rem
|
||||
|
||||
(h2 :color "#00ffff"
|
||||
:margin-bottom 1.5rem
|
||||
:text-align center))
|
||||
|
||||
(.profile-info
|
||||
:margin-bottom 2rem)
|
||||
|
||||
|
|
@ -496,13 +615,13 @@
|
|||
:padding 0.75rem 0
|
||||
:border-bottom "1px solid #2a3441"
|
||||
|
||||
("&:last-child" :border-bottom none)
|
||||
|
||||
(label :color "#ccc"
|
||||
:font-weight bold)
|
||||
|
||||
(span :color "#fff"))
|
||||
|
||||
((:and .info-group :last-child) :border-bottom none)
|
||||
|
||||
(.role-badge
|
||||
:background-color "#00ffff"
|
||||
:color "#000"
|
||||
|
|
@ -516,9 +635,122 @@
|
|||
:gap 1rem
|
||||
:justify-content center)
|
||||
|
||||
;; Additional Profile Page Styles
|
||||
(.artist-stats
|
||||
:display flex
|
||||
:flex-direction column
|
||||
:gap 0.75rem)
|
||||
|
||||
(.artist-item
|
||||
:display flex
|
||||
:justify-content space-between
|
||||
:align-items center
|
||||
:padding "0.5rem 0"
|
||||
:border-bottom "1px solid #2a3441")
|
||||
|
||||
((:and .artist-item :last-child)
|
||||
:border-bottom none)
|
||||
|
||||
(.artist-name
|
||||
:color "#e0e6ed"
|
||||
:font-weight 500)
|
||||
|
||||
(.artist-plays
|
||||
:color "#8892b0"
|
||||
:font-size 0.875rem)
|
||||
|
||||
(.track-item
|
||||
:display flex
|
||||
:justify-content space-between
|
||||
:align-items center
|
||||
:padding "0.75rem 0"
|
||||
:border-bottom "1px solid #2a3441")
|
||||
|
||||
((:and .track-item :last-child)
|
||||
:border-bottom none)
|
||||
|
||||
(.track-info
|
||||
:display flex
|
||||
:flex-direction column
|
||||
:gap 0.25rem)
|
||||
|
||||
(.track-title
|
||||
:color "#e0e6ed"
|
||||
:font-weight 500)
|
||||
|
||||
(.track-artist
|
||||
:color "#8892b0"
|
||||
:font-size 0.875rem)
|
||||
|
||||
(.track-meta
|
||||
:display flex
|
||||
:flex-direction column
|
||||
:align-items flex-end
|
||||
:gap 0.25rem
|
||||
:text-align right)
|
||||
|
||||
(.track-duration
|
||||
:color "#64ffda"
|
||||
:font-size 0.875rem
|
||||
:font-weight bold)
|
||||
|
||||
(.track-played-at
|
||||
:color "#8892b0"
|
||||
:font-size 0.75rem)
|
||||
|
||||
(.activity-chart
|
||||
:text-align center)
|
||||
|
||||
(.chart-placeholder
|
||||
:display flex
|
||||
:align-items flex-end
|
||||
:justify-content space-between
|
||||
:height 120px
|
||||
:margin "1rem 0"
|
||||
:padding "0 1rem")
|
||||
|
||||
(.chart-bar
|
||||
:width 8px
|
||||
:background-color "#64ffda"
|
||||
:border-radius "2px 2px 0 0"
|
||||
:margin "0 1px"
|
||||
:min-height 4px
|
||||
:opacity 0.8)
|
||||
|
||||
((:and .chart-bar :hover)
|
||||
:opacity 1)
|
||||
|
||||
(.chart-note
|
||||
:color "#8892b0"
|
||||
:font-size 0.875rem
|
||||
:margin-top 0.5rem)
|
||||
|
||||
(.stat-number
|
||||
:color "#64ffda"
|
||||
:font-size 1.5rem
|
||||
:font-weight bold
|
||||
:display block)
|
||||
|
||||
(.stat-text
|
||||
:color "#e0e6ed"
|
||||
:font-size 1.2rem
|
||||
:font-weight 500
|
||||
:display block)
|
||||
|
||||
;; Toast notification styles
|
||||
(.toast
|
||||
:position fixed
|
||||
:top 20px
|
||||
:right 20px
|
||||
:padding "12px 20px"
|
||||
:border-radius 4px
|
||||
:color white
|
||||
:font-weight bold
|
||||
:z-index 1000
|
||||
:transition "opacity 0.3s ease")
|
||||
|
||||
;; User Management Styles
|
||||
(.user-management
|
||||
:margin-top 2rem)
|
||||
(.user-management :margin-top 2rem)
|
||||
|
||||
(.users-table
|
||||
:width "100%"
|
||||
|
|
@ -540,11 +772,11 @@
|
|||
(tbody
|
||||
(tr :border-bottom "1px solid #2a3441"
|
||||
|
||||
("&:hover" :background-color "#222")
|
||||
|
||||
(td :padding 1rem
|
||||
:color "#fff"
|
||||
:vertical-align middle))
|
||||
((:and tr :hover) :background-color "#222")
|
||||
|
||||
|
||||
(.user-actions
|
||||
:display flex
|
||||
|
|
@ -573,10 +805,21 @@
|
|||
|
||||
(.stat-label :color "#ccc"
|
||||
:font-size 0.875rem
|
||||
:margin-top 0.5rem)))
|
||||
:margin-top 0.5rem))
|
||||
|
||||
;; Center alignment for player page
|
||||
;; (body.player-page
|
||||
;; :text-align center)
|
||||
;; (body.player-page
|
||||
;; :text-align center)
|
||||
|
||||
) ;; Close main body block
|
||||
) ;; Close main body block
|
||||
|
||||
;; media queries for reponsiveness
|
||||
(:media "(max-width: 576px)"
|
||||
(body
|
||||
(.playlist-controls
|
||||
:display block
|
||||
;;:width "100%"
|
||||
(>* :width "100%")
|
||||
(button :margin-left 0
|
||||
:margin-right 0))))
|
||||
) ;; End of let block
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
// Admin Dashboard JavaScript
|
||||
let tracks = [];
|
||||
let currentTrackId = null;
|
||||
|
||||
// Pagination variables
|
||||
let currentPage = 1;
|
||||
let tracksPerPage = 20;
|
||||
let filteredTracks = [];
|
||||
|
||||
// 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);
|
||||
|
||||
// Queue controls
|
||||
const refreshQueueBtn = document.getElementById('refresh-queue');
|
||||
const loadFromM3uBtn = document.getElementById('load-from-m3u');
|
||||
const clearQueueBtn = document.getElementById('clear-queue-btn');
|
||||
const addRandomBtn = document.getElementById('add-random-tracks');
|
||||
const queueSearchInput = document.getElementById('queue-track-search');
|
||||
|
||||
if (refreshQueueBtn) refreshQueueBtn.addEventListener('click', loadStreamQueue);
|
||||
if (loadFromM3uBtn) loadFromM3uBtn.addEventListener('click', loadQueueFromM3U);
|
||||
if (clearQueueBtn) clearQueueBtn.addEventListener('click', clearStreamQueue);
|
||||
if (addRandomBtn) addRandomBtn.addEventListener('click', addRandomTracks);
|
||||
if (queueSearchInput) queueSearchInput.addEventListener('input', searchTracksForQueue);
|
||||
|
||||
// Load initial queue
|
||||
loadStreamQueue();
|
||||
|
||||
// Setup live stream monitor
|
||||
const liveAudio = document.getElementById('live-stream-audio');
|
||||
if (liveAudio) {
|
||||
liveAudio.preload = 'none';
|
||||
}
|
||||
|
||||
// Update live stream info
|
||||
updateLiveStreamInfo();
|
||||
setInterval(updateLiveStreamInfo, 10000); // Every 10 seconds
|
||||
});
|
||||
|
||||
// Load tracks from API
|
||||
async function loadTracks() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/admin/tracks');
|
||||
const result = await response.json();
|
||||
|
||||
// Handle Radiance API response format: {status: 200, message: "Ok", data: {...}}
|
||||
const data = result.data || result;
|
||||
|
||||
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 with pagination
|
||||
function displayTracks(trackList) {
|
||||
filteredTracks = trackList;
|
||||
currentPage = 1; // Reset to first page
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const container = document.getElementById('tracks-container');
|
||||
const paginationControls = document.getElementById('pagination-controls');
|
||||
|
||||
if (filteredTracks.length === 0) {
|
||||
container.innerHTML = '<div class="no-tracks">No tracks found. Click "Scan Library" to add tracks.</div>';
|
||||
paginationControls.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredTracks.length / tracksPerPage);
|
||||
const startIndex = (currentPage - 1) * tracksPerPage;
|
||||
const endIndex = startIndex + tracksPerPage;
|
||||
const tracksToShow = filteredTracks.slice(startIndex, endIndex);
|
||||
|
||||
// Render tracks for current page
|
||||
const tracksHtml = tracksToShow.map(track => `
|
||||
<div class="track-item" data-track-id="${track.id}">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title || 'Unknown Title'}</div>
|
||||
<div class="track-artist">${track.artist || 'Unknown Artist'}</div>
|
||||
<div class="track-album">${track.album || 'Unknown Album'}</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button onclick="addToQueue(${track.id}, 'end')" class="btn btn-sm btn-primary">➕ Add to Queue</button>
|
||||
<button onclick="deleteTrack(${track.id})" class="btn btn-sm btn-danger">🗑️ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = tracksHtml;
|
||||
|
||||
// Update pagination controls
|
||||
document.getElementById('page-info').textContent = `Page ${currentPage} of ${totalPages} (${filteredTracks.length} tracks)`;
|
||||
paginationControls.style.display = totalPages > 1 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Pagination functions
|
||||
function goToPage(page) {
|
||||
const totalPages = Math.ceil(filteredTracks.length / tracksPerPage);
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
renderPage();
|
||||
}
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
renderPage();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
const totalPages = Math.ceil(filteredTracks.length / tracksPerPage);
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
renderPage();
|
||||
}
|
||||
}
|
||||
|
||||
function goToLastPage() {
|
||||
const totalPages = Math.ceil(filteredTracks.length / tracksPerPage);
|
||||
currentPage = totalPages;
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function changeTracksPerPage() {
|
||||
tracksPerPage = parseInt(document.getElementById('tracks-per-page').value);
|
||||
currentPage = 1;
|
||||
renderPage();
|
||||
}
|
||||
|
||||
// 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('/api/asteroid/admin/scan-library', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
// Handle Radiance API response format
|
||||
const data = result.data || result;
|
||||
|
||||
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('Error scanning library:', 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 || '').toLowerCase().includes(query) ||
|
||||
(track.artist || '').toLowerCase().includes(query) ||
|
||||
(track.album || '').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] || '';
|
||||
const bVal = b[sortBy] || '';
|
||||
return aVal.localeCompare(bVal);
|
||||
});
|
||||
displayTracks(sorted);
|
||||
}
|
||||
|
||||
// Audio player element
|
||||
let audioPlayer = null;
|
||||
|
||||
// Initialize audio player
|
||||
function initAudioPlayer() {
|
||||
if (!audioPlayer) {
|
||||
audioPlayer = new Audio();
|
||||
audioPlayer.addEventListener('ended', () => {
|
||||
currentTrackId = null;
|
||||
updatePlayerStatus();
|
||||
});
|
||||
audioPlayer.addEventListener('error', (e) => {
|
||||
console.error('Audio playback error:', e);
|
||||
alert('Error playing audio file');
|
||||
});
|
||||
}
|
||||
return audioPlayer;
|
||||
}
|
||||
|
||||
// Player functions
|
||||
async function playTrack(trackId) {
|
||||
if (!trackId) {
|
||||
alert('Please select a track to play');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const player = initAudioPlayer();
|
||||
player.src = `/asteroid/tracks/${trackId}/stream`;
|
||||
player.play();
|
||||
currentTrackId = trackId;
|
||||
updatePlayerStatus();
|
||||
} catch (error) {
|
||||
console.error('Play error:', error);
|
||||
alert('Error playing track');
|
||||
}
|
||||
}
|
||||
|
||||
async function pausePlayer() {
|
||||
try {
|
||||
if (audioPlayer && !audioPlayer.paused) {
|
||||
audioPlayer.pause();
|
||||
updatePlayerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Pause error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopPlayer() {
|
||||
try {
|
||||
if (audioPlayer) {
|
||||
audioPlayer.pause();
|
||||
audioPlayer.currentTime = 0;
|
||||
currentTrackId = null;
|
||||
updatePlayerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Stop error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function resumePlayer() {
|
||||
try {
|
||||
if (audioPlayer && audioPlayer.paused && currentTrackId) {
|
||||
audioPlayer.play();
|
||||
updatePlayerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Resume error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlayerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/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);
|
||||
|
||||
// ========================================
|
||||
// Stream Queue Management
|
||||
// ========================================
|
||||
|
||||
let streamQueue = [];
|
||||
let queueSearchTimeout = null;
|
||||
|
||||
// Load current stream queue
|
||||
async function loadStreamQueue() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/stream/queue');
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
streamQueue = data.queue || [];
|
||||
displayStreamQueue();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading stream queue:', error);
|
||||
document.getElementById('stream-queue-container').innerHTML =
|
||||
'<div class="error">Error loading queue</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Display stream queue
|
||||
function displayStreamQueue() {
|
||||
const container = document.getElementById('stream-queue-container');
|
||||
|
||||
if (streamQueue.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">Queue is empty. Add tracks below.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="queue-items">';
|
||||
streamQueue.forEach((item, index) => {
|
||||
if (item) {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === streamQueue.length - 1;
|
||||
html += `
|
||||
<div class="queue-item" data-track-id="${item.id}" data-index="${index}">
|
||||
<span class="queue-position">${index + 1}</span>
|
||||
<div class="queue-track-info">
|
||||
<div class="track-title">${item.title || 'Unknown'}</div>
|
||||
<div class="track-artist">${item.artist || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<div class="queue-actions">
|
||||
<button class="btn btn-sm btn-secondary" onclick="moveTrackUp(${index})" ${isFirst ? 'disabled' : ''}>⬆️</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="moveTrackDown(${index})" ${isLast ? 'disabled' : ''}>⬇️</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="removeFromQueue(${item.id})">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Clear stream queue
|
||||
async function clearStreamQueue() {
|
||||
if (!confirm('Clear the entire stream queue? This will stop playback until new tracks are added.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/stream/queue/clear', {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert('Queue cleared successfully');
|
||||
loadStreamQueue();
|
||||
} else {
|
||||
alert('Error clearing queue: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing queue:', error);
|
||||
alert('Error clearing queue');
|
||||
}
|
||||
}
|
||||
|
||||
// Load queue from M3U file
|
||||
async function loadQueueFromM3U() {
|
||||
if (!confirm('Load queue from stream-queue.m3u file? This will replace the current queue.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/stream/queue/load-m3u', {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert(`Successfully loaded ${data.count} tracks from M3U file!`);
|
||||
loadStreamQueue();
|
||||
} else {
|
||||
alert('Error loading from M3U: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading from M3U:', error);
|
||||
alert('Error loading from M3U: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Move track up in queue
|
||||
async function moveTrackUp(index) {
|
||||
if (index === 0) return;
|
||||
|
||||
// Swap with previous track
|
||||
const newQueue = [...streamQueue];
|
||||
[newQueue[index - 1], newQueue[index]] = [newQueue[index], newQueue[index - 1]];
|
||||
|
||||
await reorderQueue(newQueue);
|
||||
}
|
||||
|
||||
// Move track down in queue
|
||||
async function moveTrackDown(index) {
|
||||
if (index === streamQueue.length - 1) return;
|
||||
|
||||
// Swap with next track
|
||||
const newQueue = [...streamQueue];
|
||||
[newQueue[index], newQueue[index + 1]] = [newQueue[index + 1], newQueue[index]];
|
||||
|
||||
await reorderQueue(newQueue);
|
||||
}
|
||||
|
||||
// Reorder the queue
|
||||
async function reorderQueue(newQueue) {
|
||||
try {
|
||||
const trackIds = newQueue.map(track => track.id).join(',');
|
||||
const response = await fetch('/api/asteroid/stream/queue/reorder?track-ids=' + trackIds, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
loadStreamQueue();
|
||||
} else {
|
||||
alert('Error reordering queue: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reordering queue:', error);
|
||||
alert('Error reordering queue');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove track from queue
|
||||
async function removeFromQueue(trackId) {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/stream/queue/remove', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: `track-id=${trackId}`
|
||||
});
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
loadStreamQueue();
|
||||
} else {
|
||||
alert('Error removing track: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing track:', error);
|
||||
alert('Error removing track');
|
||||
}
|
||||
}
|
||||
|
||||
// Add track to queue
|
||||
async function addToQueue(trackId, position = 'end', showNotification = true) {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/stream/queue/add', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: `track-id=${trackId}&position=${position}`
|
||||
});
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Only reload queue if we're in the queue management section
|
||||
const queueContainer = document.getElementById('stream-queue-container');
|
||||
if (queueContainer && queueContainer.offsetParent !== null) {
|
||||
loadStreamQueue();
|
||||
}
|
||||
|
||||
// Show brief success notification
|
||||
if (showNotification) {
|
||||
showToast('✓ Added to queue');
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
alert('Error adding track: ' + (data.message || 'Unknown error'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding track:', error);
|
||||
alert('Error adding track');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple toast notification
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #00ff00;
|
||||
color: #000;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
z-index: 10000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Add random tracks to queue
|
||||
async function addRandomTracks() {
|
||||
if (tracks.length === 0) {
|
||||
alert('No tracks available. Please scan the library first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const count = 10;
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
const selected = shuffled.slice(0, Math.min(count, tracks.length));
|
||||
|
||||
for (const track of selected) {
|
||||
await addToQueue(track.id, 'end', false); // Don't show toast for each track
|
||||
}
|
||||
|
||||
showToast(`✓ Added ${selected.length} random tracks to queue`);
|
||||
}
|
||||
|
||||
// Search tracks for adding to queue
|
||||
function searchTracksForQueue(event) {
|
||||
clearTimeout(queueSearchTimeout);
|
||||
const query = event.target.value.toLowerCase();
|
||||
|
||||
if (query.length < 2) {
|
||||
document.getElementById('queue-track-results').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
queueSearchTimeout = setTimeout(() => {
|
||||
const results = tracks.filter(track =>
|
||||
(track.title && track.title.toLowerCase().includes(query)) ||
|
||||
(track.artist && track.artist.toLowerCase().includes(query)) ||
|
||||
(track.album && track.album.toLowerCase().includes(query))
|
||||
).slice(0, 20); // Limit to 20 results
|
||||
|
||||
displayQueueSearchResults(results);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Display search results for queue
|
||||
function displayQueueSearchResults(results) {
|
||||
const container = document.getElementById('queue-track-results');
|
||||
|
||||
if (results.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No tracks found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="search-results">';
|
||||
results.forEach(track => {
|
||||
html += `
|
||||
<div class="search-result-item">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title || 'Unknown'}</div>
|
||||
<div class="track-artist">${track.artist || 'Unknown'} - ${track.album || 'Unknown Album'}</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="addToQueue(${track.id}, 'end')">Add to End</button>
|
||||
<button class="btn btn-sm btn-success" onclick="addToQueue(${track.id}, 'next')">Play Next</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Live stream info update
|
||||
async function updateLiveStreamInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (!contentType.includes('text/plain')) {
|
||||
console.error('Unexpected content type:', contentType);
|
||||
return;
|
||||
}
|
||||
|
||||
const nowPlayingText = await response.text();
|
||||
const nowPlayingEl = document.getElementById('live-now-playing');
|
||||
|
||||
if (nowPlayingEl) {
|
||||
nowPlayingEl.textContent = nowPlayingText;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Could not fetch stream info:', error);
|
||||
const nowPlayingEl = document.getElementById('live-now-playing');
|
||||
if (nowPlayingEl) {
|
||||
nowPlayingEl.textContent = 'Error loading stream info';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// auth-ui.js - Handle authentication UI state across all pages
|
||||
|
||||
// Check if user is logged in by calling the API
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/auth-status');
|
||||
const result = await response.json();
|
||||
// api-output wraps response in {status, message, data}
|
||||
const data = result.data || result;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error checking auth status:', error);
|
||||
return { loggedIn: false, isAdmin: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI based on authentication status
|
||||
function updateAuthUI(authStatus) {
|
||||
// Show/hide elements based on login status
|
||||
document.querySelectorAll('[data-show-if-logged-in]').forEach(el => {
|
||||
el.style.display = authStatus.loggedIn ? 'inline-block' : 'none';
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-show-if-logged-out]').forEach(el => {
|
||||
el.style.display = authStatus.loggedIn ? 'none' : 'inline-block';
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-show-if-admin]').forEach(el => {
|
||||
el.style.display = authStatus.isAdmin ? 'inline-block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize auth UI on page load
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
console.log('Auth UI initializing...');
|
||||
const authStatus = await checkAuthStatus();
|
||||
console.log('Auth status:', authStatus);
|
||||
updateAuthUI(authStatus);
|
||||
console.log('Auth UI updated');
|
||||
});
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
// Stream quality configuration
|
||||
function getStreamConfig(streamBaseUrl, encoding) {
|
||||
const config = {
|
||||
aac: {
|
||||
url: `${streamBaseUrl}/asteroid.aac`,
|
||||
format: 'AAC 96kbps Stereo',
|
||||
type: 'audio/aac',
|
||||
mount: 'asteroid.aac'
|
||||
},
|
||||
mp3: {
|
||||
url: `${streamBaseUrl}/asteroid.mp3`,
|
||||
format: 'MP3 128kbps Stereo',
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid.mp3'
|
||||
},
|
||||
low: {
|
||||
url: `${streamBaseUrl}/asteroid-low.mp3`,
|
||||
format: 'MP3 64kbps Stereo',
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid-low.mp3'
|
||||
}
|
||||
};
|
||||
|
||||
return config[encoding]
|
||||
};
|
||||
|
||||
// Change stream quality
|
||||
function changeStreamQuality() {
|
||||
const selector = document.getElementById('stream-quality');
|
||||
const streamBaseUrl = document.getElementById('stream-base-url');
|
||||
const config = getStreamConfig(streamBaseUrl.value, selector.value);
|
||||
|
||||
// Update UI elements
|
||||
document.getElementById('stream-url').textContent = config.url;
|
||||
document.getElementById('stream-format').textContent = config.format;
|
||||
|
||||
// Update Station Status stream quality display
|
||||
const statusQuality = document.querySelector('[data-text="stream-quality"]');
|
||||
if (statusQuality) {
|
||||
statusQuality.textContent = config.format;
|
||||
}
|
||||
|
||||
// Update audio player
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
const sourceElement = document.getElementById('audio-source');
|
||||
|
||||
const wasPlaying = !audioElement.paused;
|
||||
const currentTime = audioElement.currentTime;
|
||||
|
||||
sourceElement.src = config.url;
|
||||
sourceElement.type = config.type;
|
||||
audioElement.load();
|
||||
|
||||
// Resume playback if it was playing
|
||||
if (wasPlaying) {
|
||||
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
}
|
||||
}
|
||||
|
||||
// Update now playing info from Icecast
|
||||
async function updateNowPlaying() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing')
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType.includes('text/html')) {
|
||||
throw new Error('Error connecting to stream')
|
||||
}
|
||||
|
||||
const data = await response.text()
|
||||
document.getElementById('now-playing').innerHTML = data
|
||||
|
||||
} catch(error) {
|
||||
console.log('Could not fetch stream status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize stream quality display on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
// Set initial quality display to match the selected stream
|
||||
const selector = document.getElementById('stream-quality');
|
||||
const streamBaseUrl = document.getElementById('stream-base-url');
|
||||
const config = getStreamConfig(streamBaseUrl.value, selector.value);
|
||||
document.getElementById('stream-url').textContent = config.url;
|
||||
document.getElementById('stream-format').textContent = config.format;
|
||||
|
||||
const statusQuality = document.querySelector('[data-text="stream-quality"]');
|
||||
if (statusQuality) {
|
||||
statusQuality.textContent = config.format;
|
||||
}
|
||||
// Update playing information right after load
|
||||
updateNowPlaying();
|
||||
|
||||
// Auto-reconnect on stream errors
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
if (audioElement) {
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.log('Stream error, attempting reconnect in 3 seconds...');
|
||||
setTimeout(function() {
|
||||
audioElement.load();
|
||||
audioElement.play().catch(err => console.log('Reconnect failed:', err));
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('stalled', function() {
|
||||
console.log('Stream stalled, reloading...');
|
||||
audioElement.load();
|
||||
audioElement.play().catch(err => console.log('Reload failed:', err));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update every 10 seconds
|
||||
setInterval(updateNowPlaying, 10000);
|
||||
|
||||
// Pop-out player functionality
|
||||
let popoutWindow = null;
|
||||
|
||||
function openPopoutPlayer() {
|
||||
// Check if popout is already open
|
||||
if (popoutWindow && !popoutWindow.closed) {
|
||||
popoutWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate centered position
|
||||
const width = 420;
|
||||
const height = 300;
|
||||
const left = (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 2;
|
||||
|
||||
// Open popout window
|
||||
const features = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=no,status=no,menubar=no,toolbar=no,location=no`;
|
||||
|
||||
popoutWindow = window.open('/asteroid/popout-player', 'AsteroidPlayer', features);
|
||||
|
||||
// Update button state
|
||||
updatePopoutButton(true);
|
||||
}
|
||||
|
||||
function updatePopoutButton(isOpen) {
|
||||
const btn = document.getElementById('popout-btn');
|
||||
if (btn) {
|
||||
if (isOpen) {
|
||||
btn.textContent = '✓ Player Open';
|
||||
btn.classList.remove('btn-info');
|
||||
btn.classList.add('btn-success');
|
||||
} else {
|
||||
btn.textContent = '🗗 Pop Out Player';
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for messages from popout window
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data.type === 'popout-opened') {
|
||||
updatePopoutButton(true);
|
||||
} else if (event.data.type === 'popout-closed') {
|
||||
updatePopoutButton(false);
|
||||
popoutWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if popout is still open periodically
|
||||
setInterval(function() {
|
||||
if (popoutWindow && popoutWindow.closed) {
|
||||
updatePopoutButton(false);
|
||||
popoutWindow = null;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Frameset mode functionality
|
||||
function enableFramesetMode() {
|
||||
// Save preference
|
||||
localStorage.setItem('useFrameset', 'true');
|
||||
// Redirect to frameset wrapper
|
||||
window.location.href = '/asteroid/frameset';
|
||||
}
|
||||
|
||||
function disableFramesetMode() {
|
||||
// Clear preference
|
||||
localStorage.removeItem('useFrameset');
|
||||
// Redirect to regular view
|
||||
window.location.href = '/asteroid/';
|
||||
}
|
||||
|
||||
// Check if user prefers frameset mode on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const path = window.location.pathname;
|
||||
const isFramesetPage = path.includes('/frameset') || path.includes('/content') ||
|
||||
path.includes('/audio-player-frame') || path.includes('/player-content');
|
||||
|
||||
if (localStorage.getItem('useFrameset') === 'true' && !isFramesetPage && path === '/asteroid/') {
|
||||
// User wants frameset but is on regular front page, redirect
|
||||
window.location.href = '/asteroid/frameset';
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,589 @@
|
|||
// Web Player JavaScript
|
||||
let tracks = [];
|
||||
let currentTrack = null;
|
||||
let currentTrackIndex = -1;
|
||||
let playQueue = [];
|
||||
let isShuffled = false;
|
||||
let isRepeating = false;
|
||||
let audioPlayer = null;
|
||||
|
||||
// Pagination variables for track library
|
||||
let libraryCurrentPage = 1;
|
||||
let libraryTracksPerPage = 20;
|
||||
let filteredLibraryTracks = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
audioPlayer = document.getElementById('audio-player');
|
||||
loadTracks();
|
||||
loadPlaylists();
|
||||
setupEventListeners();
|
||||
updatePlayerDisplay();
|
||||
updateVolume();
|
||||
|
||||
// Setup live stream with reduced buffering
|
||||
const liveAudio = document.getElementById('live-stream-audio');
|
||||
if (liveAudio) {
|
||||
// Reduce buffer to minimize delay
|
||||
liveAudio.preload = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
if (audioPlayer) {
|
||||
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('/api/asteroid/tracks');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
tracks = data.tracks || [];
|
||||
displayTracks(tracks);
|
||||
} else {
|
||||
console.error('Error loading tracks:', data.error);
|
||||
document.getElementById('track-list').innerHTML = '<div class="error">Error loading tracks</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tracks:', error);
|
||||
document.getElementById('track-list').innerHTML = '<div class="error">Error loading tracks</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayTracks(trackList) {
|
||||
filteredLibraryTracks = trackList;
|
||||
libraryCurrentPage = 1;
|
||||
renderLibraryPage();
|
||||
}
|
||||
|
||||
function renderLibraryPage() {
|
||||
const container = document.getElementById('track-list');
|
||||
const paginationControls = document.getElementById('library-pagination-controls');
|
||||
|
||||
if (filteredLibraryTracks.length === 0) {
|
||||
container.innerHTML = '<div class="no-tracks">No tracks found</div>';
|
||||
paginationControls.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredLibraryTracks.length / libraryTracksPerPage);
|
||||
const startIndex = (libraryCurrentPage - 1) * libraryTracksPerPage;
|
||||
const endIndex = startIndex + libraryTracksPerPage;
|
||||
const tracksToShow = filteredLibraryTracks.slice(startIndex, endIndex);
|
||||
|
||||
// Render tracks for current page
|
||||
const tracksHtml = tracksToShow.map((track, pageIndex) => {
|
||||
// Find the actual index in the full tracks array
|
||||
const actualIndex = tracks.findIndex(t => t.id === track.id);
|
||||
return `
|
||||
<div class="track-item" data-track-id="${track.id}" data-index="${actualIndex}">
|
||||
<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(${actualIndex})" class="btn btn-sm btn-success">▶️</button>
|
||||
<button onclick="addToQueue(${actualIndex})" class="btn btn-sm btn-info">➕</button>
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
|
||||
container.innerHTML = tracksHtml;
|
||||
|
||||
// Update pagination controls
|
||||
document.getElementById('library-page-info').textContent = `Page ${libraryCurrentPage} of ${totalPages} (${filteredLibraryTracks.length} tracks)`;
|
||||
paginationControls.style.display = totalPages > 1 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Library pagination functions
|
||||
function libraryGoToPage(page) {
|
||||
const totalPages = Math.ceil(filteredLibraryTracks.length / libraryTracksPerPage);
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
libraryCurrentPage = page;
|
||||
renderLibraryPage();
|
||||
}
|
||||
}
|
||||
|
||||
function libraryPreviousPage() {
|
||||
if (libraryCurrentPage > 1) {
|
||||
libraryCurrentPage--;
|
||||
renderLibraryPage();
|
||||
}
|
||||
}
|
||||
|
||||
function libraryNextPage() {
|
||||
const totalPages = Math.ceil(filteredLibraryTracks.length / libraryTracksPerPage);
|
||||
if (libraryCurrentPage < totalPages) {
|
||||
libraryCurrentPage++;
|
||||
renderLibraryPage();
|
||||
}
|
||||
}
|
||||
|
||||
function libraryGoToLastPage() {
|
||||
const totalPages = Math.ceil(filteredLibraryTracks.length / libraryTracksPerPage);
|
||||
libraryCurrentPage = totalPages;
|
||||
renderLibraryPage();
|
||||
}
|
||||
|
||||
function changeLibraryTracksPerPage() {
|
||||
libraryTracksPerPage = parseInt(document.getElementById('library-tracks-per-page').value);
|
||||
libraryCurrentPage = 1;
|
||||
renderLibraryPage();
|
||||
}
|
||||
|
||||
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/asteroid/player/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;
|
||||
if (audioPlayer) {
|
||||
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();
|
||||
}
|
||||
|
||||
async function createPlaylist() {
|
||||
const name = document.getElementById('new-playlist-name').value.trim();
|
||||
if (!name) {
|
||||
alert('Please enter a playlist name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('description', '');
|
||||
|
||||
const response = await fetch('/api/asteroid/playlists/create', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
alert(`Playlist "${name}" created successfully!`);
|
||||
document.getElementById('new-playlist-name').value = '';
|
||||
|
||||
// Wait a moment then reload playlists
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
loadPlaylists();
|
||||
} else {
|
||||
alert('Error creating playlist: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating playlist:', error);
|
||||
alert('Error creating playlist: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQueueAsPlaylist() {
|
||||
if (playQueue.length === 0) {
|
||||
alert('Queue is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = prompt('Enter playlist name:');
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
// First create the playlist
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('description', `Created from queue with ${playQueue.length} tracks`);
|
||||
|
||||
const createResponse = await fetch('/api/asteroid/playlists/create', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const createResult = await createResponse.json();
|
||||
|
||||
if (createResult.status === 'success') {
|
||||
// Wait a moment for database to update
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Get the new playlist ID by fetching playlists
|
||||
const playlistsResponse = await fetch('/api/asteroid/playlists');
|
||||
const playlistsResult = await playlistsResponse.json();
|
||||
|
||||
if (playlistsResult.status === 'success' && playlistsResult.playlists.length > 0) {
|
||||
// Find the playlist with matching name (most recent)
|
||||
const newPlaylist = playlistsResult.playlists.find(p => p.name === name) ||
|
||||
playlistsResult.playlists[playlistsResult.playlists.length - 1];
|
||||
|
||||
// Add all tracks from queue to playlist
|
||||
let addedCount = 0;
|
||||
for (const track of playQueue) {
|
||||
const trackId = track.id || (Array.isArray(track.id) ? track.id[0] : null);
|
||||
|
||||
if (trackId) {
|
||||
const addFormData = new FormData();
|
||||
addFormData.append('playlist-id', newPlaylist.id);
|
||||
addFormData.append('track-id', trackId);
|
||||
|
||||
const addResponse = await fetch('/api/asteroid/playlists/add-track', {
|
||||
method: 'POST',
|
||||
body: addFormData
|
||||
});
|
||||
|
||||
const addResult = await addResponse.json();
|
||||
|
||||
if (addResult.status === 'success') {
|
||||
addedCount++;
|
||||
}
|
||||
} else {
|
||||
console.error('Track has no valid ID:', track);
|
||||
}
|
||||
}
|
||||
|
||||
alert(`Playlist "${name}" created with ${addedCount} tracks!`);
|
||||
loadPlaylists();
|
||||
} else {
|
||||
alert('Playlist created but could not add tracks. Error: ' + (playlistsResult.message || 'Unknown'));
|
||||
}
|
||||
} else {
|
||||
alert('Error creating playlist: ' + createResult.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving queue as playlist:', error);
|
||||
alert('Error saving queue as playlist: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlaylists() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/playlists');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.data && result.data.status === 'success') {
|
||||
displayPlaylists(result.data.playlists || []);
|
||||
} else if (result.status === 'success') {
|
||||
displayPlaylists(result.playlists || []);
|
||||
} else {
|
||||
displayPlaylists([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading playlists:', error);
|
||||
displayPlaylists([]);
|
||||
}
|
||||
}
|
||||
|
||||
function displayPlaylists(playlists) {
|
||||
const container = document.getElementById('playlists-container');
|
||||
|
||||
if (!playlists || playlists.length === 0) {
|
||||
container.innerHTML = '<div class="no-playlists">No playlists created yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const playlistsHtml = playlists.map(playlist => `
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-name">${playlist.name}</div>
|
||||
<div class="playlist-meta">${playlist['track-count']} tracks</div>
|
||||
</div>
|
||||
<div class="playlist-actions">
|
||||
<button onclick="loadPlaylist(${playlist.id})" class="btn btn-sm btn-info">📂 Load</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = playlistsHtml;
|
||||
}
|
||||
|
||||
async function loadPlaylist(playlistId) {
|
||||
try {
|
||||
const response = await fetch(`/api/asteroid/playlists/get?playlist-id=${playlistId}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success' && result.playlist) {
|
||||
const playlist = result.playlist;
|
||||
|
||||
// Clear current queue
|
||||
playQueue = [];
|
||||
|
||||
// Add all playlist tracks to queue
|
||||
if (playlist.tracks && playlist.tracks.length > 0) {
|
||||
playlist.tracks.forEach(track => {
|
||||
// Find the full track object from our tracks array
|
||||
const fullTrack = tracks.find(t => t.id === track.id);
|
||||
if (fullTrack) {
|
||||
playQueue.push(fullTrack);
|
||||
}
|
||||
});
|
||||
|
||||
updateQueueDisplay();
|
||||
alert(`Loaded ${playQueue.length} tracks from "${playlist.name}" into queue!`);
|
||||
|
||||
// Optionally start playing the first track
|
||||
if (playQueue.length > 0) {
|
||||
const firstTrack = playQueue.shift();
|
||||
const trackIndex = tracks.findIndex(t => t.id === firstTrack.id);
|
||||
if (trackIndex >= 0) {
|
||||
playTrack(trackIndex);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert(`Playlist "${playlist.name}" is empty`);
|
||||
}
|
||||
} else {
|
||||
alert('Error loading playlist: ' + (result.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading playlist:', error);
|
||||
alert('Error loading playlist: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Stream quality configuration (same as front page)
|
||||
function getLiveStreamConfig(streamBaseUrl, quality) {
|
||||
const config = {
|
||||
aac: {
|
||||
url: `${streamBaseUrl}/asteroid.aac`,
|
||||
type: 'audio/aac',
|
||||
mount: 'asteroid.aac'
|
||||
},
|
||||
mp3: {
|
||||
url: `${streamBaseUrl}/asteroid.mp3`,
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid.mp3'
|
||||
},
|
||||
low: {
|
||||
url: `${streamBaseUrl}/asteroid-low.mp3`,
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid-low.mp3'
|
||||
}
|
||||
};
|
||||
|
||||
return config[quality];
|
||||
};
|
||||
|
||||
// Change live stream quality
|
||||
function changeLiveStreamQuality() {
|
||||
const streamBaseUrl = document.getElementById('stream-base-url');
|
||||
const selector = document.getElementById('live-stream-quality');
|
||||
const config = getLiveStreamConfig(streamBaseUrl.value, selector.value);
|
||||
|
||||
// Update audio player
|
||||
const audioElement = document.getElementById('live-stream-audio');
|
||||
const sourceElement = document.getElementById('live-stream-source');
|
||||
|
||||
const wasPlaying = !audioElement.paused;
|
||||
|
||||
sourceElement.src = config.url;
|
||||
sourceElement.type = config.type;
|
||||
audioElement.load();
|
||||
|
||||
// Resume playback if it was playing
|
||||
if (wasPlaying) {
|
||||
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
}
|
||||
}
|
||||
|
||||
// Live stream informatio update
|
||||
async function updateNowPlaying() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing')
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType.includes('text/html')) {
|
||||
throw new Error('Error connecting to stream')
|
||||
}
|
||||
|
||||
const data = await response.text()
|
||||
document.getElementById('now-playing').innerHTML = data
|
||||
|
||||
} catch(error) {
|
||||
console.log('Could not fetch stream status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial update after 1 second
|
||||
setTimeout(updateNowPlaying, 1000);
|
||||
// Update live stream info every 10 seconds
|
||||
setInterval(updateNowPlaying, 10000);
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
// Profile page JavaScript functionality
|
||||
// Handles user profile data loading and interactions
|
||||
|
||||
let currentUser = null;
|
||||
let listeningData = null;
|
||||
|
||||
// Load profile data on page initialization
|
||||
function loadProfileData() {
|
||||
console.log('Loading profile data...');
|
||||
|
||||
// Load user info
|
||||
fetch('/api/asteroid/user/profile')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
// api-output wraps response in {status, message, data}
|
||||
const data = result.data || result;
|
||||
if (data.status === 'success') {
|
||||
currentUser = data.user;
|
||||
updateProfileDisplay(data.user);
|
||||
} else {
|
||||
console.error('Failed to load profile:', data.message);
|
||||
showError('Failed to load profile data');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading profile:', error);
|
||||
showError('Error loading profile data');
|
||||
});
|
||||
|
||||
// Load listening statistics
|
||||
loadListeningStats();
|
||||
|
||||
// Load recent tracks
|
||||
loadRecentTracks();
|
||||
|
||||
// Load top artists
|
||||
loadTopArtists();
|
||||
}
|
||||
|
||||
function updateProfileDisplay(user) {
|
||||
// Update basic user info
|
||||
updateElement('username', user.username || 'Unknown User');
|
||||
updateElement('user-role', formatRole(user.role || 'listener'));
|
||||
updateElement('join-date', formatDate(user.created_at || new Date()));
|
||||
updateElement('last-active', formatRelativeTime(user.last_active || new Date()));
|
||||
|
||||
// Show/hide admin link based on role
|
||||
const adminLink = document.querySelector('[data-show-if-admin]');
|
||||
if (adminLink) {
|
||||
adminLink.style.display = (user.role === 'admin') ? 'inline' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function loadListeningStats() {
|
||||
fetch('/api/asteroid/user/listening-stats')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
const data = result.data || result;
|
||||
if (data.status === 'success') {
|
||||
const stats = data.stats;
|
||||
updateElement('total-listen-time', formatDuration(stats.total_listen_time || 0));
|
||||
updateElement('tracks-played', stats.tracks_played || 0);
|
||||
updateElement('session-count', stats.session_count || 0);
|
||||
updateElement('favorite-genre', stats.favorite_genre || 'Unknown');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading listening stats:', error);
|
||||
// Set default values
|
||||
updateElement('total-listen-time', '0h 0m');
|
||||
updateElement('tracks-played', '0');
|
||||
updateElement('session-count', '0');
|
||||
updateElement('favorite-genre', 'Unknown');
|
||||
});
|
||||
}
|
||||
|
||||
function loadRecentTracks() {
|
||||
fetch('/api/asteroid/user/recent-tracks?limit=3')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
const data = result.data || result;
|
||||
if (data.status === 'success' && data.tracks && data.tracks.length > 0) {
|
||||
data.tracks.forEach((track, index) => {
|
||||
const trackNum = index + 1;
|
||||
updateElement(`recent-track-${trackNum}-title`, track.title || 'Unknown Track');
|
||||
updateElement(`recent-track-${trackNum}-artist`, track.artist || 'Unknown Artist');
|
||||
updateElement(`recent-track-${trackNum}-duration`, formatDuration(track.duration || 0));
|
||||
updateElement(`recent-track-${trackNum}-played-at`, formatRelativeTime(track.played_at));
|
||||
});
|
||||
} else {
|
||||
// Hide empty track items
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const trackItem = document.querySelector(`[data-text="recent-track-${i}-title"]`)?.closest('.track-item');
|
||||
if (trackItem && (!data.tracks || !data.tracks[i-1])) {
|
||||
trackItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading recent tracks:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadTopArtists() {
|
||||
fetch('/api/asteroid/user/top-artists?limit=5')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
const data = result.data || result;
|
||||
if (data.status === 'success' && data.artists && data.artists.length > 0) {
|
||||
data.artists.forEach((artist, index) => {
|
||||
const artistNum = index + 1;
|
||||
updateElement(`top-artist-${artistNum}`, artist.name || 'Unknown Artist');
|
||||
updateElement(`top-artist-${artistNum}-plays`, `${artist.play_count || 0} plays`);
|
||||
});
|
||||
} else {
|
||||
// Hide empty artist items
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const artistItem = document.querySelector(`[data-text="top-artist-${i}"]`)?.closest('.artist-item');
|
||||
if (artistItem && (!data.artists || !data.artists[i-1])) {
|
||||
artistItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading top artists:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadMoreRecentTracks() {
|
||||
// TODO: Implement pagination for recent tracks
|
||||
console.log('Loading more recent tracks...');
|
||||
showMessage('Loading more tracks...', 'info');
|
||||
}
|
||||
|
||||
function editProfile() {
|
||||
// TODO: Implement profile editing modal or redirect
|
||||
console.log('Edit profile clicked');
|
||||
showMessage('Profile editing coming soon!', 'info');
|
||||
}
|
||||
|
||||
function exportListeningData() {
|
||||
console.log('Exporting listening data...');
|
||||
showMessage('Preparing data export...', 'info');
|
||||
|
||||
fetch('/api/asteroid/user/export-data', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `asteroid-listening-data-${currentUser?.username || 'user'}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showMessage('Data exported successfully!', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error exporting data:', error);
|
||||
showMessage('Failed to export data', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function clearListeningHistory() {
|
||||
if (!confirm('Are you sure you want to clear your listening history? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Clearing listening history...');
|
||||
showMessage('Clearing listening history...', 'info');
|
||||
|
||||
fetch('/api/asteroid/user/clear-history', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
showMessage('Listening history cleared successfully!', 'success');
|
||||
// Reload the page data
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
showMessage('Failed to clear history: ' + data.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error clearing history:', error);
|
||||
showMessage('Failed to clear history', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function updateElement(dataText, value) {
|
||||
const element = document.querySelector(`[data-text="${dataText}"]`);
|
||||
if (element && value !== undefined && value !== null) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRole(role) {
|
||||
const roleMap = {
|
||||
'admin': '👑 Admin',
|
||||
'dj': '🎧 DJ',
|
||||
'listener': '🎵 Listener'
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
} else if (diffMinutes > 0) {
|
||||
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
// Create a simple toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
|
||||
// Set background color based on type
|
||||
const colors = {
|
||||
'info': '#007bff',
|
||||
'success': '#28a745',
|
||||
'error': '#dc3545',
|
||||
'warning': '#ffc107'
|
||||
};
|
||||
toast.style.backgroundColor = colors[type] || colors.info;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Fade in
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '1';
|
||||
}, 100);
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
showMessage(message, 'error');
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
// Load user stats on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadUserStats();
|
||||
});
|
||||
|
||||
async function loadUserStats() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/user-stats');
|
||||
const result = await response.json();
|
||||
|
||||
// api-output wraps response in {status, message, data}
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success' && data.stats) {
|
||||
const stats = data.stats;
|
||||
document.getElementById('total-users').textContent = stats['total-users'] || 0;
|
||||
document.getElementById('active-users').textContent = stats['active-users'] || 0;
|
||||
document.getElementById('admin-users').textContent = stats['admins'] || 0;
|
||||
document.getElementById('dj-users').textContent = stats['djs'] || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/users');
|
||||
const result = await response.json();
|
||||
|
||||
// api-output wraps response in {status, message, data}
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
showUsersTable(data.users);
|
||||
document.getElementById('users-list-section').style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
alert('Error loading users. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function showUsersTable(users) {
|
||||
const container = document.getElementById('users-container');
|
||||
container.innerHTML = `
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(user => `
|
||||
<tr>
|
||||
<td>${user.username}</td>
|
||||
<td>${user.email}</td>
|
||||
<td>
|
||||
<select onchange="updateUserRole('${user.id}', this.value)">
|
||||
<option value="listener" ${user.role === 'listener' ? 'selected' : ''}>Listener</option>
|
||||
<option value="dj" ${user.role === 'dj' ? 'selected' : ''}>DJ</option>
|
||||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>${user.active ? '✅ Active' : '❌ Inactive'}</td>
|
||||
<td>${user['last-login'] ? new Date(user['last-login'] * 1000).toLocaleString() : 'Never'}</td>
|
||||
<td class="user-actions">
|
||||
${user.active ?
|
||||
`<button class="btn btn-danger" onclick="deactivateUser('${user.id}')">Deactivate</button>` :
|
||||
`<button class="btn btn-success" onclick="activateUser('${user.id}')">Activate</button>`
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-secondary" onclick="hideUsersTable()">Close</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function hideUsersTable() {
|
||||
document.getElementById('users-list-section').style.display = 'none';
|
||||
}
|
||||
|
||||
async function updateUserRole(userId, newRole) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('role', newRole);
|
||||
|
||||
const response = await fetch(`/api/asteroid/users/${userId}/role`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
loadUserStats();
|
||||
alert('User role updated successfully');
|
||||
} else {
|
||||
alert('Error updating user role: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user role:', error);
|
||||
alert('Error updating user role. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateUser(userId) {
|
||||
if (!confirm('Are you sure you want to deactivate this user?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/asteroid/users/${userId}/deactivate`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
loadUsers();
|
||||
loadUserStats();
|
||||
alert('User deactivated successfully');
|
||||
} else {
|
||||
alert('Error deactivating user: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deactivating user:', error);
|
||||
alert('Error deactivating user. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function activateUser(userId) {
|
||||
try {
|
||||
const response = await fetch(`/api/asteroid/users/${userId}/activate`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
loadUsers();
|
||||
loadUserStats();
|
||||
alert('User activated successfully');
|
||||
} else {
|
||||
alert('Error activating user: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error activating user:', error);
|
||||
alert('Error activating user. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCreateUserForm() {
|
||||
const form = document.getElementById('create-user-form');
|
||||
if (form.style.display === 'none') {
|
||||
form.style.display = 'block';
|
||||
// Clear form
|
||||
document.getElementById('new-username').value = '';
|
||||
document.getElementById('new-email').value = '';
|
||||
document.getElementById('new-password').value = '';
|
||||
document.getElementById('new-role').value = 'listener';
|
||||
} else {
|
||||
form.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewUser(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const username = document.getElementById('new-username').value;
|
||||
const email = document.getElementById('new-email').value;
|
||||
const password = document.getElementById('new-password').value;
|
||||
const role = document.getElementById('new-role').value;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('email', email);
|
||||
formData.append('password', password);
|
||||
formData.append('role', role);
|
||||
|
||||
const response = await fetch('/api/asteroid/users/create', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// api-output wraps response in {status, message, data}
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert(`User "${username}" created successfully!`);
|
||||
toggleCreateUserForm();
|
||||
loadUserStats();
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('Error creating user: ' + (data.message || result.message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
alert('Error creating user. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update user stats every 30 seconds
|
||||
setInterval(loadUserStats, 30000);
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
;;;; stream-control.lisp - Stream Queue and Playlist Control for Asteroid Radio
|
||||
;;;; Manages the main broadcast stream queue and generates M3U playlists for Liquidsoap
|
||||
|
||||
(in-package :asteroid)
|
||||
|
||||
;;; Stream Queue Management
|
||||
;;; The stream queue represents what will play on the main broadcast
|
||||
|
||||
(defvar *stream-queue* '() "List of track IDs queued for streaming")
|
||||
(defvar *stream-history* '() "List of recently played track IDs")
|
||||
(defvar *max-history-size* 50 "Maximum number of tracks to keep in history")
|
||||
|
||||
(defun get-stream-queue ()
|
||||
"Get the current stream queue"
|
||||
*stream-queue*)
|
||||
|
||||
(defun add-to-stream-queue (track-id &optional (position :end))
|
||||
"Add a track to the stream queue at specified position (:end or :next)"
|
||||
(case position
|
||||
(:next (push track-id *stream-queue*))
|
||||
(:end (setf *stream-queue* (append *stream-queue* (list track-id))))
|
||||
(t (error "Position must be :next or :end")))
|
||||
(regenerate-stream-playlist)
|
||||
t)
|
||||
|
||||
(defun remove-from-stream-queue (track-id)
|
||||
"Remove a track from the stream queue"
|
||||
(setf *stream-queue* (remove track-id *stream-queue* :test #'equal))
|
||||
(regenerate-stream-playlist)
|
||||
t)
|
||||
|
||||
(defun clear-stream-queue ()
|
||||
"Clear the entire stream queue"
|
||||
(setf *stream-queue* '())
|
||||
(regenerate-stream-playlist)
|
||||
t)
|
||||
|
||||
(defun reorder-stream-queue (track-ids)
|
||||
"Reorder the stream queue with a new list of track IDs"
|
||||
(setf *stream-queue* track-ids)
|
||||
(regenerate-stream-playlist)
|
||||
t)
|
||||
|
||||
(defun add-playlist-to-stream-queue (playlist-id)
|
||||
"Add all tracks from a playlist to the stream queue"
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when playlist
|
||||
(let* ((track-ids-raw (gethash "track-ids" playlist))
|
||||
(track-ids-str (if (listp track-ids-raw)
|
||||
(first track-ids-raw)
|
||||
track-ids-raw))
|
||||
(track-ids (if (and track-ids-str
|
||||
(stringp track-ids-str)
|
||||
(not (string= track-ids-str "")))
|
||||
(mapcar #'parse-integer
|
||||
(cl-ppcre:split "," track-ids-str))
|
||||
nil)))
|
||||
(dolist (track-id track-ids)
|
||||
(add-to-stream-queue track-id :end))
|
||||
t))))
|
||||
|
||||
;;; M3U Playlist Generation
|
||||
|
||||
(defun get-track-file-path (track-id)
|
||||
"Get the file path for a track by ID"
|
||||
(let ((track (get-track-by-id track-id)))
|
||||
(when track
|
||||
(let ((file-path (gethash "file-path" track)))
|
||||
(if (listp file-path)
|
||||
(first file-path)
|
||||
file-path)))))
|
||||
|
||||
(defun convert-to-docker-path (host-path)
|
||||
"Convert host file path to Docker container path"
|
||||
;; Replace the music library path with /app/music/
|
||||
(let ((library-prefix (namestring *music-library-path*)))
|
||||
(if (and (stringp host-path)
|
||||
(>= (length host-path) (length library-prefix))
|
||||
(string= host-path library-prefix :end1 (length library-prefix)))
|
||||
(concatenate 'string "/app/music/"
|
||||
(subseq host-path (length library-prefix)))
|
||||
host-path)))
|
||||
|
||||
(defun generate-m3u-playlist (track-ids output-path)
|
||||
"Generate an M3U playlist file from a list of track IDs"
|
||||
(with-open-file (stream output-path
|
||||
:direction :output
|
||||
:if-exists :supersede
|
||||
:if-does-not-exist :create)
|
||||
(format stream "#EXTM3U~%")
|
||||
(dolist (track-id track-ids)
|
||||
(let ((file-path (get-track-file-path track-id)))
|
||||
(when file-path
|
||||
(let ((docker-path (convert-to-docker-path file-path)))
|
||||
(format stream "#EXTINF:0,~%")
|
||||
(format stream "~a~%" docker-path))))))
|
||||
t)
|
||||
|
||||
(defun regenerate-stream-playlist ()
|
||||
"Regenerate the main stream playlist from the current queue"
|
||||
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(if (null *stream-queue*)
|
||||
;; If queue is empty, generate from all tracks (fallback)
|
||||
(let ((all-tracks (db:select "tracks" (db:query :all))))
|
||||
(generate-m3u-playlist
|
||||
(mapcar (lambda (track)
|
||||
(let ((id (gethash "_id" track)))
|
||||
(if (listp id) (first id) id)))
|
||||
all-tracks)
|
||||
playlist-path))
|
||||
;; Generate from queue
|
||||
(generate-m3u-playlist *stream-queue* playlist-path))))
|
||||
|
||||
(defun export-playlist-to-m3u (playlist-id output-path)
|
||||
"Export a user playlist to an M3U file"
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when playlist
|
||||
(let* ((track-ids-raw (gethash "track-ids" playlist))
|
||||
(track-ids-str (if (listp track-ids-raw)
|
||||
(first track-ids-raw)
|
||||
track-ids-raw))
|
||||
(track-ids (if (and track-ids-str
|
||||
(stringp track-ids-str)
|
||||
(not (string= track-ids-str "")))
|
||||
(mapcar #'parse-integer
|
||||
(cl-ppcre:split "," track-ids-str))
|
||||
nil)))
|
||||
(generate-m3u-playlist track-ids output-path)))))
|
||||
|
||||
;;; Stream History Management
|
||||
|
||||
(defun add-to-stream-history (track-id)
|
||||
"Add a track to the stream history"
|
||||
(push track-id *stream-history*)
|
||||
;; Keep history size limited
|
||||
(when (> (length *stream-history*) *max-history-size*)
|
||||
(setf *stream-history* (subseq *stream-history* 0 *max-history-size*)))
|
||||
t)
|
||||
|
||||
(defun get-stream-history (&optional (count 10))
|
||||
"Get recent stream history (default 10 tracks)"
|
||||
(subseq *stream-history* 0 (min count (length *stream-history*))))
|
||||
|
||||
;;; Smart Queue Building
|
||||
|
||||
(defun build-smart-queue (genre &optional (count 20))
|
||||
"Build a smart queue based on genre"
|
||||
(let ((tracks (db:select "tracks" (db:query :all))))
|
||||
;; For now, just add random tracks
|
||||
;; TODO: Implement genre filtering when we have genre metadata
|
||||
(let ((track-ids (mapcar (lambda (track)
|
||||
(let ((id (gethash "_id" track)))
|
||||
(if (listp id) (first id) id)))
|
||||
tracks)))
|
||||
(setf *stream-queue* (subseq (alexandria:shuffle track-ids)
|
||||
0
|
||||
(min count (length track-ids))))
|
||||
(regenerate-stream-playlist)
|
||||
*stream-queue*)))
|
||||
|
||||
(defun build-queue-from-artist (artist-name &optional (count 20))
|
||||
"Build a queue from tracks by a specific artist"
|
||||
(let ((tracks (db:select "tracks" (db:query :all))))
|
||||
(let ((matching-tracks
|
||||
(remove-if-not
|
||||
(lambda (track)
|
||||
(let ((artist (gethash "artist" track)))
|
||||
(when artist
|
||||
(let ((artist-str (if (listp artist) (first artist) artist)))
|
||||
(search artist-name artist-str :test #'char-equal)))))
|
||||
tracks)))
|
||||
(let ((track-ids (mapcar (lambda (track)
|
||||
(let ((id (gethash "_id" track)))
|
||||
(if (listp id) (first id) id)))
|
||||
matching-tracks)))
|
||||
(setf *stream-queue* (subseq track-ids 0 (min count (length track-ids))))
|
||||
(regenerate-stream-playlist)
|
||||
*stream-queue*))))
|
||||
|
||||
(defun convert-from-docker-path (docker-path)
|
||||
"Convert Docker container path back to host file path"
|
||||
(if (and (stringp docker-path)
|
||||
(>= (length docker-path) 11)
|
||||
(string= docker-path "/app/music/" :end1 11))
|
||||
(concatenate 'string
|
||||
(namestring *music-library-path*)
|
||||
(subseq docker-path 11))
|
||||
docker-path))
|
||||
|
||||
(defun load-queue-from-m3u-file ()
|
||||
"Load the stream queue from the stream-queue.m3u file"
|
||||
(let* ((m3u-path (merge-pathnames "stream-queue.m3u"
|
||||
(asdf:system-source-directory :asteroid)))
|
||||
(track-ids '())
|
||||
(all-tracks (db:select "tracks" (db:query :all))))
|
||||
|
||||
(when (probe-file m3u-path)
|
||||
(with-open-file (stream m3u-path :direction :input)
|
||||
(loop for line = (read-line stream nil)
|
||||
while line
|
||||
do (unless (or (string= line "")
|
||||
(char= (char line 0) #\#))
|
||||
;; This is a file path line
|
||||
(let* ((docker-path (string-trim '(#\Space #\Tab #\Return #\Newline) line))
|
||||
(host-path (convert-from-docker-path docker-path)))
|
||||
;; Find track by file path
|
||||
(let ((track (find-if
|
||||
(lambda (trk)
|
||||
(let ((fp (gethash "file-path" trk)))
|
||||
(let ((file-path (if (listp fp) (first fp) fp)))
|
||||
(string= file-path host-path))))
|
||||
all-tracks)))
|
||||
(when track
|
||||
(let ((id (gethash "_id" track)))
|
||||
(push (if (listp id) (first id) id) track-ids)))))))))
|
||||
|
||||
;; Reverse to maintain order from file
|
||||
(setf track-ids (nreverse track-ids))
|
||||
(setf *stream-queue* track-ids)
|
||||
(regenerate-stream-playlist)
|
||||
(length track-ids)))
|
||||
|
|
@ -13,9 +13,11 @@
|
|||
(cl-fad:list-directory directory :follow-symlinks nil))))
|
||||
|
||||
(defun scan-directory-for-music-recursively (path)
|
||||
(loop for directory in (uiop:subdirectories path)
|
||||
with music = (scan-directory-for-music path)
|
||||
appending (scan-directory-for-music directory)))
|
||||
"Recursively scan directory and all subdirectories for music files"
|
||||
(let ((files-in-current-dir (scan-directory-for-music path))
|
||||
(files-in-subdirs (loop for directory in (uiop:subdirectories path)
|
||||
appending (scan-directory-for-music-recursively directory))))
|
||||
(append files-in-current-dir files-in-subdirs)))
|
||||
|
||||
(defun extract-metadata-with-taglib (file-path)
|
||||
"Extract metadata using taglib library"
|
||||
|
|
@ -57,45 +59,66 @@
|
|||
:duration 0
|
||||
:bitrate 0))))
|
||||
|
||||
(defun track-exists-p (file-path)
|
||||
"Check if a track with the given file path already exists in the database"
|
||||
;; Try direct query first
|
||||
(let ((existing (db:select "tracks" (db:query (:= "file-path" file-path)))))
|
||||
(if (> (length existing) 0)
|
||||
t
|
||||
;; If not found, search manually (file-path might be stored as list)
|
||||
(let ((all-tracks (db:select "tracks" (db:query :all))))
|
||||
(some (lambda (track)
|
||||
(let ((stored-path (gethash "file-path" track)))
|
||||
(or (equal stored-path file-path)
|
||||
(and (listp stored-path) (equal (first stored-path) file-path)))))
|
||||
all-tracks)))))
|
||||
|
||||
(defun insert-track-to-database (metadata)
|
||||
"Insert track metadata into database"
|
||||
"Insert track metadata into database if it doesn't already exist"
|
||||
;; Ensure tracks collection exists
|
||||
(unless (db:collection-exists-p "tracks")
|
||||
(error "Tracks collection does not exist in database"))
|
||||
|
||||
;; Check if track already exists
|
||||
(let ((file-path (getf metadata :file-path)))
|
||||
(if (track-exists-p file-path)
|
||||
nil
|
||||
(progn
|
||||
(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 "file-path" 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))))
|
||||
(list "play-count" 0)))
|
||||
t))))
|
||||
|
||||
(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-recursively directory))
|
||||
(added-count 0))
|
||||
(added-count 0)
|
||||
(skipped-count 0))
|
||||
(dolist (file audio-files)
|
||||
(let ((metadata (extract-metadata-with-taglib file)))
|
||||
(when metadata
|
||||
(handler-case
|
||||
(progn
|
||||
(insert-track-to-database metadata)
|
||||
(if (insert-track-to-database metadata)
|
||||
(incf added-count)
|
||||
(format t "Added: ~a~%" (getf metadata :file-path)))
|
||||
(incf skipped-count))
|
||||
(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))))
|
||||
(defun initialize-music-directories (&optional (base-dir *music-library-path*))
|
||||
"Create necessary music directories if they don't exist"
|
||||
(progn
|
||||
(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)))
|
||||
(ensure-directories-exist (merge-pathnames "temp/" base-dir))))
|
||||
|
||||
;; Simple file copy endpoint for manual uploads
|
||||
(define-page copy-files #@"/admin/copy-files" ()
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
#EXTM3U
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
;;;; template-utils.lisp - CLIP Template Processing Utilities
|
||||
;;;; Proper CLIP-based template rendering using keyword arguments
|
||||
|
||||
(in-package :asteroid)
|
||||
|
||||
;; Template directory configuration
|
||||
(defparameter *template-directory*
|
||||
(merge-pathnames "template/" (asdf:system-source-directory :asteroid))
|
||||
"Base directory for all CLIP templates")
|
||||
|
||||
;; Template cache for parsed templates
|
||||
(defvar *template-cache* (make-hash-table :test 'equal)
|
||||
"Cache for parsed template DOMs")
|
||||
|
||||
(defun template-path (name)
|
||||
"Build full path to template file.
|
||||
NAME can be either:
|
||||
- Simple name: 'front-page' -> 'template/front-page.ctml'
|
||||
- Path with subdirs: 'partial/now-playing' -> 'template/partial/now-playing.ctml'"
|
||||
(merge-pathnames (format nil "~a.ctml" name) *template-directory*))
|
||||
|
||||
(defun load-template (name)
|
||||
"Load and parse a template by name without caching.
|
||||
Use this for templates that change frequently during development."
|
||||
(plump:parse (alexandria:read-file-into-string (template-path name))))
|
||||
|
||||
(defun get-template (template-name)
|
||||
"Load and cache a template file.
|
||||
Use this for production - templates are cached after first load."
|
||||
(or (gethash template-name *template-cache*)
|
||||
(let ((parsed (load-template template-name)))
|
||||
(setf (gethash template-name *template-cache*) parsed)
|
||||
parsed)))
|
||||
|
||||
(defun clear-template-cache ()
|
||||
"Clear the template cache (useful during development)"
|
||||
(clrhash *template-cache*))
|
||||
|
||||
(defun render-template-with-plist (template-name &rest plist)
|
||||
"Render a template with plist-style arguments - CLIP's standard way
|
||||
|
||||
CLIP's process-to-string accepts keyword arguments directly and makes them
|
||||
available via (clip:clipboard key-name) in attribute processors.
|
||||
|
||||
Example:
|
||||
(render-template-with-plist \"admin\"
|
||||
:title \"Admin Dashboard\"
|
||||
:server-status \"🟢 Running\")"
|
||||
(let ((template (get-template template-name)))
|
||||
;; CLIP's standard approach: pass keywords directly
|
||||
(apply #'clip:process-to-string template plist)))
|
||||
|
||||
;; Custom CLIP attribute processor for text replacement
|
||||
;; This is the proper CLIP way - define processors for custom attributes
|
||||
(clip:define-attribute-processor data-text (node value)
|
||||
"Process data-text attribute - replaces node text content with clipboard value
|
||||
Usage: <span data-text=\"key-name\">Default Text</span>"
|
||||
(plump:clear node)
|
||||
(plump:make-text-node node (clip:clipboard value)))
|
||||
|
|
@ -1,505 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - Admin Dashboard</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎛️ ADMIN DASHBOARD</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/">← Back to Main</a>
|
||||
<a href="/asteroid/player/">Web Player</a>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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="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="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="card">
|
||||
<h3>🎵 Player Control</h3>
|
||||
<div class="player-controls">
|
||||
<button class="btn btn-primary" onclick="playTrack()">▶️ Play</button>
|
||||
<button class="btn btn-secondary" onclick="pausePlayer()">⏸️ Pause</button>
|
||||
<button class="btn btn-secondary" onclick="stopPlayer()">⏹️ Stop</button>
|
||||
<button class="btn btn-secondary" onclick="resumePlayer()">▶️ Resume</button>
|
||||
</div>
|
||||
<div id="player-status" class="status-info">
|
||||
Status: <span id="status-text">Unknown</span><br>
|
||||
Current Track: <span id="current-track">None</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>👥 User Management</h3>
|
||||
<div class="user-stats" id="user-stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="total-users">0</span>
|
||||
<span class="stat-label">Total Users</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="active-users">0</span>
|
||||
<span class="stat-label">Active Users</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="admin-users">0</span>
|
||||
<span class="stat-label">Admins</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="dj-users">0</span>
|
||||
<span class="stat-label">DJs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="btn btn-primary" onclick="loadUsers()">👥 Manage Users</button>
|
||||
<button class="btn btn-secondary" onclick="showCreateUser()">➕ Create User</button>
|
||||
</div>
|
||||
</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 || 'Unknown Title'}</div>
|
||||
<div class="track-artist">${track.artist || 'Unknown Artist'}</div>
|
||||
<div class="track-album">${track.album || '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 || '').toLowerCase().includes(query) ||
|
||||
(track.artist || '').toLowerCase().includes(query) ||
|
||||
(track.album || '').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] : ''; */
|
||||
const aVal = a[sortBy] ? a[sortBy] : '';
|
||||
const bVal = b[sortBy] ? b[sortBy] : '';
|
||||
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.');
|
||||
}
|
||||
|
||||
// User Management Functions
|
||||
async function loadUserStats() {
|
||||
try {
|
||||
const response = await fetch('/asteroid/api/users/stats');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
const stats = result.stats;
|
||||
document.getElementById('total-users').textContent = stats.total;
|
||||
document.getElementById('active-users').textContent = stats.active;
|
||||
document.getElementById('admin-users').textContent = stats.admins;
|
||||
document.getElementById('dj-users').textContent = stats.djs;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch('/asteroid/api/users');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
showUsersTable(result.users);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
alert('Error loading users. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function showUsersTable(users) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'user-management';
|
||||
container.innerHTML = `
|
||||
<h3>User Management</h3>
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(user => `
|
||||
<tr>
|
||||
<td>${user.username}</td>
|
||||
<td>${user.email}</td>
|
||||
<td>
|
||||
<select onchange="updateUserRole('${user.id}', this.value)">
|
||||
<option value="listener" ${user.role === 'listener' ? 'selected' : ''}>Listener</option>
|
||||
<option value="dj" ${user.role === 'dj' ? 'selected' : ''}>DJ</option>
|
||||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>${user.active ? '✅ Active' : '❌ Inactive'}</td>
|
||||
<td>${user['last-login'] ? new Date(user['last-login'] * 1000).toLocaleString() : 'Never'}</td>
|
||||
<td class="user-actions">
|
||||
${user.active ?
|
||||
`<button class="btn btn-danger" onclick="deactivateUser('${user.id}')">Deactivate</button>` :
|
||||
`<button class="btn btn-success" onclick="activateUser('${user.id}')">Activate</button>`
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-secondary" onclick="hideUsersTable()">Close</button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
function hideUsersTable() {
|
||||
const userManagement = document.querySelector('.user-management');
|
||||
if (userManagement) {
|
||||
userManagement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserRole(userId, newRole) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('role', newRole);
|
||||
|
||||
const response = await fetch(`/asteroid/api/users/${userId}/role`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
loadUserStats();
|
||||
alert('User role updated successfully');
|
||||
} else {
|
||||
alert('Error updating user role: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user role:', error);
|
||||
alert('Error updating user role. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateUser(userId) {
|
||||
if (!confirm('Are you sure you want to deactivate this user?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/asteroid/api/users/${userId}/deactivate`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
loadUsers();
|
||||
loadUserStats();
|
||||
alert('User deactivated successfully');
|
||||
} else {
|
||||
alert('Error deactivating user: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deactivating user:', error);
|
||||
alert('Error deactivating user. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateUser() {
|
||||
window.location.href = '/asteroid/register';
|
||||
}
|
||||
|
||||
// Load user stats on page load
|
||||
loadUserStats();
|
||||
|
||||
// Update player status every 5 seconds
|
||||
setInterval(updatePlayerStatus, 5000);
|
||||
|
||||
// Update user stats every 30 seconds
|
||||
setInterval(loadUserStats, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - Admin Dashboard</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/admin.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎛️ ADMIN DASHBOARD</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/player-content" target="content-frame">Player</a>
|
||||
<a href="/asteroid/profile" target="content-frame">Profile</a>
|
||||
<a href="/asteroid/admin/users">👥 Users</a>
|
||||
<a href="/asteroid/logout" class="btn-logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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="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>
|
||||
<span id="scan-status" style="margin-left: 15px; font-weight: bold;"></span>
|
||||
</div>
|
||||
|
||||
<div class="track-stats">
|
||||
<p>Total Tracks: <span id="track-count" data-text="track-count">0</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>
|
||||
</select>
|
||||
<select id="tracks-per-page" class="sort-select" onchange="changeTracksPerPage()">
|
||||
<option value="10">10 per page</option>
|
||||
<option value="20" selected>20 per page</option>
|
||||
<option value="50">50 per page</option>
|
||||
<option value="100">100 per page</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="tracks-container" class="tracks-list">
|
||||
<div class="loading">Loading tracks...</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div id="pagination-controls" style="display: none; margin-top: 20px; text-align: center;">
|
||||
<button onclick="goToPage(1)" class="btn btn-secondary">« First</button>
|
||||
<button onclick="previousPage()" class="btn btn-secondary">‹ Prev</button>
|
||||
<span id="page-info" style="margin: 0 15px; font-weight: bold;">Page 1 of 1</span>
|
||||
<button onclick="nextPage()" class="btn btn-secondary">Next ›</button>
|
||||
<button onclick="goToLastPage()" class="btn btn-secondary">Last »</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Stream Monitor -->
|
||||
<div class="admin-section">
|
||||
<h2>📻 Live Stream Monitor</h2>
|
||||
<div class="live-stream-monitor">
|
||||
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
|
||||
<p><strong>Now Playing:</strong> <span id="live-now-playing">Loading...</span></p>
|
||||
<audio id="live-stream-audio" controls style="width: 100%; max-width: 600px;">
|
||||
<source id="live-stream-source" lquery="(attr :src default-stream-url)" type="audio/aac">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stream Queue Management -->
|
||||
<div class="admin-section">
|
||||
<h2>🎵 Stream Queue Management</h2>
|
||||
<p>Manage the live stream playback queue. Changes take effect within 5-10 seconds.</p>
|
||||
|
||||
<div class="queue-controls">
|
||||
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
|
||||
<button id="load-from-m3u" class="btn btn-success">📂 Load Queue from M3U</button>
|
||||
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
|
||||
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random Tracks</button>
|
||||
</div>
|
||||
|
||||
<div id="stream-queue-container" class="queue-list">
|
||||
<div class="loading">Loading queue...</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-actions">
|
||||
<h3>Add Tracks to Queue</h3>
|
||||
<input type="text" id="queue-track-search" placeholder="Search tracks to add..." class="search-input">
|
||||
<div id="queue-track-results" class="track-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Control -->
|
||||
<div class="admin-section">
|
||||
<h2>Player Control</h2>
|
||||
<div class="card">
|
||||
<h3>🎵 Player Control</h3>
|
||||
<div class="player-controls">
|
||||
<button id="player-play" class="btn btn-primary" onclick="playTrack()">▶️ Play</button>
|
||||
<button id="player-pause" class="btn btn-secondary" onclick="pausePlayer()">⏸️ Pause</button>
|
||||
<button id="player-stop" class="btn btn-secondary" onclick="stopPlayer()">⏹️ Stop</button>
|
||||
<button id="player-resume" class="btn btn-secondary" onclick="resumePlayer()">▶️ Resume</button>
|
||||
</div>
|
||||
<div id="player-status" class="status-info">
|
||||
Status: <span id="player-state">Unknown</span><br>
|
||||
Current Track: <span id="current-track">None</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>👥 User Management</h3>
|
||||
<p>Manage user accounts, roles, and permissions.</p>
|
||||
<div class="controls">
|
||||
<a href="/asteroid/admin/users" class="btn btn-primary">👥 Manage Users</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background: #1a1a1a;
|
||||
font-family: 'VT323', monospace;
|
||||
}
|
||||
.persistent-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.player-label {
|
||||
color: #00ff00;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.quality-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.quality-selector label {
|
||||
color: #00ff00;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.quality-selector select {
|
||||
background: #2a2a2a;
|
||||
color: #00ff00;
|
||||
border: 1px solid #00ff00;
|
||||
padding: 3px 8px;
|
||||
font-family: 'VT323', monospace;
|
||||
}
|
||||
audio {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.now-playing-mini {
|
||||
color: #00ff00;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="persistent-player">
|
||||
<span class="player-label">🟢 LIVE:</span>
|
||||
|
||||
<div class="quality-selector">
|
||||
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
|
||||
<label for="stream-quality">Quality:</label>
|
||||
<select id="stream-quality" onchange="changeStreamQuality()">
|
||||
<option value="aac">AAC 96k</option>
|
||||
<option value="mp3">MP3 128k</option>
|
||||
<option value="low">MP3 64k</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<audio id="persistent-audio" controls preload="metadata">
|
||||
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
|
||||
</audio>
|
||||
|
||||
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
|
||||
|
||||
<button onclick="disableFramesetMode()" style="background: #2a2a2a; color: #00ff00; border: 1px solid #00ff00; padding: 5px 10px; cursor: pointer; font-family: 'VT323', monospace; font-size: 0.85em; white-space: nowrap;">
|
||||
✕ Disable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Configure audio element for better streaming
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const audioElement = document.getElementById('persistent-audio');
|
||||
|
||||
// Try to enable low-latency mode if supported
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: 'Asteroid Radio Live Stream',
|
||||
artist: 'Asteroid Radio',
|
||||
album: 'Live Broadcast'
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listeners for debugging
|
||||
audioElement.addEventListener('waiting', function() {
|
||||
console.log('Audio buffering...');
|
||||
});
|
||||
|
||||
audioElement.addEventListener('playing', function() {
|
||||
console.log('Audio playing');
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.error('Audio error:', e);
|
||||
});
|
||||
});
|
||||
|
||||
// Stream quality configuration
|
||||
function getStreamConfig(streamBaseUrl, encoding) {
|
||||
const config = {
|
||||
aac: {
|
||||
url: streamBaseUrl + '/asteroid.aac',
|
||||
type: 'audio/aac'
|
||||
},
|
||||
mp3: {
|
||||
url: streamBaseUrl + '/asteroid.mp3',
|
||||
type: 'audio/mpeg'
|
||||
},
|
||||
low: {
|
||||
url: streamBaseUrl + '/asteroid-low.mp3',
|
||||
type: 'audio/mpeg'
|
||||
}
|
||||
};
|
||||
return config[encoding];
|
||||
}
|
||||
|
||||
// Change stream quality
|
||||
function changeStreamQuality() {
|
||||
const selector = document.getElementById('stream-quality');
|
||||
const streamBaseUrl = document.getElementById('stream-base-url').value;
|
||||
const config = getStreamConfig(streamBaseUrl, selector.value);
|
||||
|
||||
const audioElement = document.getElementById('persistent-audio');
|
||||
const sourceElement = document.getElementById('audio-source');
|
||||
|
||||
const wasPlaying = !audioElement.paused;
|
||||
|
||||
sourceElement.src = config.url;
|
||||
sourceElement.type = config.type;
|
||||
audioElement.load();
|
||||
|
||||
if (wasPlaying) {
|
||||
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
}
|
||||
}
|
||||
|
||||
// Update mini now playing display
|
||||
async function updateMiniNowPlaying() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
document.getElementById('mini-now-playing').textContent = text;
|
||||
}
|
||||
} catch(error) {
|
||||
console.log('Could not fetch now playing:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update every 10 seconds
|
||||
setTimeout(updateMiniNowPlaying, 1000);
|
||||
setInterval(updateMiniNowPlaying, 10000);
|
||||
|
||||
// Disable frameset mode function
|
||||
function disableFramesetMode() {
|
||||
// Clear preference
|
||||
localStorage.removeItem('useFrameset');
|
||||
// Redirect parent window to regular view
|
||||
window.parent.location.href = '/asteroid/';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title lquery="(text title)">🎵 ASTEROID RADIO 🎵</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script>
|
||||
// Prevent nested framesets - break out if we're already in a frame
|
||||
if (window.self !== window.top) {
|
||||
window.top.location.href = window.self.location.href;
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<frameset rows="*,80" frameborder="0" border="0" framespacing="0">
|
||||
<frame src="/asteroid/content" name="content-frame" noresize>
|
||||
<frame src="/asteroid/audio-player-frame" name="player-frame" noresize scrolling="no">
|
||||
<noframes>
|
||||
<body>
|
||||
<p>Your browser does not support frames. Please use a modern browser or visit <a href="/asteroid/content">the main site</a>.</p>
|
||||
</body>
|
||||
</noframes>
|
||||
</frameset>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">🎵 ASTEROID RADIO 🎵</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/front-page.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1 data-text="station-name">🎵 ASTEROID RADIO 🎵</h1>
|
||||
<nav class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/player-content" target="content-frame">Player</a>
|
||||
<a href="/asteroid/status" target="content-frame">Status</a>
|
||||
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/login" target="content-frame" data-show-if-logged-out>Login</a>
|
||||
<a href="/asteroid/register" target="content-frame" data-show-if-logged-out>Register</a>
|
||||
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="status">
|
||||
<h2>Station Status</h2>
|
||||
<p data-text="status-message">🟢 LIVE - Broadcasting asteroid music for hackers</p>
|
||||
<p>Current listeners: <span data-text="listeners">0</span></p>
|
||||
<p>Stream quality: <span data-text="stream-quality">AAC 96kbps Stereo</span></p>
|
||||
</div>
|
||||
|
||||
<div class="live-stream">
|
||||
<h2 style="color: #00ff00;">🟢 LIVE STREAM</h2>
|
||||
<p><em>The live stream player is now in the persistent bar at the bottom of the page.</em></p>
|
||||
<p><strong>Stream URL:</strong> <code id="stream-url" lquery="(text default-stream-url)"></code></p>
|
||||
<p><strong>Format:</strong> <span id="stream-format" lquery="(text default-stream-encoding-desc)"></span></p>
|
||||
<p><strong>Status:</strong> <span id="stream-status" style="color: #00ff00;">● BROADCASTING</span></p>
|
||||
</div>
|
||||
|
||||
<div id="now-playing" class="now-playing"></div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">🎵 ASTEROID RADIO 🎵</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1 data-text="station-name">🎵 ASTEROID RADIO 🎵</h1>
|
||||
<nav>
|
||||
<a href="/asteroid/">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
<a href="/asteroid/admin">Admin</a>
|
||||
<a href="/asteroid/status">Status</a>
|
||||
<a href="/asteroid/login">Login</a>
|
||||
<a href="/asteroid/register">Register</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="status">
|
||||
<h2>Station Status</h2>
|
||||
<p data-text="status-message">🟢 LIVE - Broadcasting asteroid music for hackers</p>
|
||||
<p>Current listeners: <span data-text="listeners">0</span></p>
|
||||
<p>Stream quality: <span data-text="stream-quality">128kbps MP3</span></p>
|
||||
</div>
|
||||
|
||||
<div class="live-stream">
|
||||
<h2>🔴 LIVE STREAM</h2>
|
||||
|
||||
<!-- Stream Quality Selector -->
|
||||
<div style="margin: 10px 0;">
|
||||
<label for="stream-quality"><strong>Quality:</strong></label>
|
||||
<select id="stream-quality" onchange="changeStreamQuality()" style="margin-left: 10px; padding: 5px;">
|
||||
<option value="aac">AAC 96kbps (Recommended)</option>
|
||||
<option value="mp3">MP3 128kbps (Compatible)</option>
|
||||
<option value="low">MP3 64kbps (Low Bandwidth)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p><strong>Stream URL:</strong> <code id="stream-url">http://localhost:8000/asteroid.aac</code></p>
|
||||
<p><strong>Format:</strong> <span id="stream-format">AAC 96kbps Stereo</span></p>
|
||||
<p><strong>Status:</strong> <span style="color: #00ff00;">● BROADCASTING</span></p>
|
||||
|
||||
<audio id="live-audio" controls style="width: 100%; margin: 10px 0;">
|
||||
<source id="audio-source" src="http://localhost:8000/asteroid.aac" type="audio/aac">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div class="now-playing">
|
||||
<h2>Now Playing</h2>
|
||||
<p>Artist: <span data-text="now-playing-artist">The Void</span></p>
|
||||
<p>Track: <span data-text="now-playing-track">Silence</span></p>
|
||||
<p>Listeners: <span data-text="listeners">0</span></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Stream quality configuration
|
||||
const streamConfig = {
|
||||
aac: {
|
||||
url: 'http://localhost:8000/asteroid.aac',
|
||||
format: 'AAC 96kbps Stereo',
|
||||
type: 'audio/aac',
|
||||
mount: 'asteroid.aac'
|
||||
},
|
||||
mp3: {
|
||||
url: 'http://localhost:8000/asteroid.mp3',
|
||||
format: 'MP3 128kbps Stereo',
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid.mp3'
|
||||
},
|
||||
low: {
|
||||
url: 'http://localhost:8000/asteroid-low.mp3',
|
||||
format: 'MP3 64kbps Stereo',
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid-low.mp3'
|
||||
}
|
||||
};
|
||||
|
||||
// Change stream quality
|
||||
function changeStreamQuality() {
|
||||
const selector = document.getElementById('stream-quality');
|
||||
const config = streamConfig[selector.value];
|
||||
|
||||
// Update UI elements
|
||||
document.getElementById('stream-url').textContent = config.url;
|
||||
document.getElementById('stream-format').textContent = config.format;
|
||||
|
||||
// Update audio player
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
const sourceElement = document.getElementById('audio-source');
|
||||
|
||||
const wasPlaying = !audioElement.paused;
|
||||
const currentTime = audioElement.currentTime;
|
||||
|
||||
sourceElement.src = config.url;
|
||||
sourceElement.type = config.type;
|
||||
audioElement.load();
|
||||
|
||||
// Resume playback if it was playing
|
||||
if (wasPlaying) {
|
||||
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
}
|
||||
}
|
||||
|
||||
// Update now playing info from Icecast
|
||||
function updateNowPlaying() {
|
||||
fetch('/asteroid/api/icecast-status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.icestats && data.icestats.source) {
|
||||
// Find the high quality stream (asteroid.mp3)
|
||||
const sources = Array.isArray(data.icestats.source) ? data.icestats.source : [data.icestats.source];
|
||||
const mainStream = sources.find(s => s.listenurl && s.listenurl.includes('asteroid.mp3'));
|
||||
|
||||
if (mainStream && mainStream.title) {
|
||||
// Parse "Artist - Track" format
|
||||
const titleParts = mainStream.title.split(' - ');
|
||||
const artist = titleParts.length > 1 ? titleParts[0] : 'Unknown Artist';
|
||||
const track = titleParts.length > 1 ? titleParts.slice(1).join(' - ') : mainStream.title;
|
||||
|
||||
document.querySelector('[data-text="now-playing-artist"]').textContent = artist;
|
||||
document.querySelector('[data-text="now-playing-track"]').textContent = track;
|
||||
document.querySelector('[data-text="listeners"]').textContent = mainStream.listeners || '0';
|
||||
|
||||
// Update stream status
|
||||
const statusElement = document.querySelector('.live-stream p:nth-child(3) span');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = '● LIVE - ' + track;
|
||||
statusElement.style.color = '#00ff00';
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.log('Could not fetch stream status:', error));
|
||||
}
|
||||
|
||||
// Update every 10 seconds
|
||||
updateNowPlaying();
|
||||
setInterval(updateNowPlaying, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">🎵 ASTEROID RADIO 🎵</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/front-page.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1 data-text="station-name">🎵 ASTEROID RADIO 🎵</h1>
|
||||
<nav class="nav">
|
||||
<a href="/asteroid/">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
<a href="/asteroid/status">Status</a>
|
||||
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/login" data-show-if-logged-out>Login</a>
|
||||
<a href="/asteroid/register" data-show-if-logged-out>Register</a>
|
||||
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="status">
|
||||
<h2>Station Status</h2>
|
||||
<p data-text="status-message">🟢 LIVE - Broadcasting asteroid music for hackers</p>
|
||||
<p>Current listeners: <span data-text="listeners">0</span></p>
|
||||
<p>Stream quality: <span data-text="stream-quality">AAC 96kbps Stereo</span></p>
|
||||
</div>
|
||||
|
||||
<div class="live-stream">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h2 style="color: #00ff00; margin: 0;">🟢 LIVE STREAM</h2>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button id="popout-btn" class="btn btn-info" onclick="openPopoutPlayer()" style="font-size: 0.9em;">
|
||||
🗗 Pop Out Player
|
||||
</button>
|
||||
<button id="frameset-btn" class="btn btn-secondary" onclick="enableFramesetMode()" style="font-size: 0.9em;">
|
||||
🖼️ Enable Persistent Player
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stream Quality Selector -->
|
||||
<div class="live-stream-quality">
|
||||
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
|
||||
<label for="stream-quality" ><strong>Quality:</strong></label>
|
||||
<select id="stream-quality" onchange="changeStreamQuality()">
|
||||
<option value="aac">AAC 96kbps (Recommended)</option>
|
||||
<option value="mp3">MP3 128kbps (Compatible)</option>
|
||||
<option value="low">MP3 64kbps (Low Bandwidth)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p><strong>Stream URL:</strong> <code id="stream-url" lquery="(text default-stream-url)"></code></p>
|
||||
<p><strong>Format:</strong> <span id="stream-format" lquery="(text default-stream-encoding-desc)"></span></p>
|
||||
<p><strong>Status:</strong> <span id="stream-status" style="color: #00ff00;">● BROADCASTING</span></p>
|
||||
|
||||
<audio id="live-audio" controls style="width: 100%; margin: 10px 0;">
|
||||
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div id="now-playing" class="now-playing"></div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -8,11 +8,20 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎵 ASTEROID RADIO - LOGIN</h1>
|
||||
<nav class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/player-content" target="content-frame">Player</a>
|
||||
<a href="/asteroid/status" target="content-frame">Status</a>
|
||||
<a href="/asteroid/register">Register</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-form">
|
||||
<h2>System Access</h2>
|
||||
<div class="message error" data-attr="style" data-attr-value="display-error">
|
||||
<div class="message error" data-attr="style" data-attr-value="display-error" style="display: none;">
|
||||
<span data-text="error-message">Invalid username or password</span>
|
||||
</div>
|
||||
<form method="post" action="/asteroid/login">
|
||||
|
|
@ -30,8 +39,8 @@
|
|||
</form>
|
||||
<div class="panel" style="margin-top: 20px; text-align: center;">
|
||||
<strong style="color: #ff6600;">Default Admin Credentials:</strong><br>
|
||||
Username: <code style="color: #00ff00;">admin</code><br>
|
||||
Password: <code style="color: #00ff00;">asteroid123</code>
|
||||
Username: <br><code style="color: #00ff00;">admin</code><br>
|
||||
Password: <br><code style="color: #00ff00;">asteroid123</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<h2>Now Playing</h2>
|
||||
<c:if test="stats">
|
||||
<c:then>
|
||||
<c:using value="stats">
|
||||
<!--<p>Artist: <span>The Void</span></p>-->
|
||||
<p>Track: <span lquery="(text title)">The Void - Silence</span></p>
|
||||
<p>Listeners: <span lquery="(text listeners)">1</span></p>
|
||||
</c:using>
|
||||
</c:then>
|
||||
<c:else>
|
||||
<c:if test="connection-error">
|
||||
<c:then>
|
||||
<div class="message error">
|
||||
<span>There was an error trying to get information from stream.</span>
|
||||
</div>
|
||||
</c:then>
|
||||
</c:if>
|
||||
<p>Track: <span>NA</span></p>
|
||||
<p>Listeners: <span>NA</span></p>
|
||||
</c:else>
|
||||
</c:if>
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - Web Player</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/player.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 WEB PLAYER</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/login" target="content-frame" data-show-if-logged-out>Login</a>
|
||||
<a href="/asteroid/register" target="content-frame" data-show-if-logged-out>Register</a>
|
||||
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<!-- Live Stream Section - Note about persistent player -->
|
||||
<div class="player-section">
|
||||
<h2 style="color: #00ff00;">🟢 Live Radio Stream</h2>
|
||||
<p><em>The live stream player is now in the persistent bar at the bottom of the page. It will continue playing as you navigate between pages!</em></p>
|
||||
</div>
|
||||
|
||||
<div id="now-playing" class="now-playing"></div>
|
||||
|
||||
<!-- Track Browser -->
|
||||
<div class="player-section">
|
||||
<h2>Personal Track Library</h2>
|
||||
<div class="track-browser">
|
||||
<input type="text" id="search-tracks" placeholder="Search tracks..." class="search-input">
|
||||
<select id="library-tracks-per-page" class="sort-select" onchange="changeLibraryTracksPerPage()" style="margin: 10px 0px;">
|
||||
<option value="10">10 per page</option>
|
||||
<option value="20" selected>20 per page</option>
|
||||
<option value="50">50 per page</option>
|
||||
</select>
|
||||
<div id="track-list" class="track-list">
|
||||
<div class="loading">Loading tracks...</div>
|
||||
</div>
|
||||
<!-- Pagination Controls -->
|
||||
<div id="library-pagination-controls" style="display: none; margin-top: 20px; text-align: center;">
|
||||
<button onclick="libraryGoToPage(1)" class="btn btn-secondary">« First</button>
|
||||
<button onclick="libraryPreviousPage()" class="btn btn-secondary">‹ Prev</button>
|
||||
<span id="library-page-info" style="margin: 0 15px; font-weight: bold;">Page 1 of 1</span>
|
||||
<button onclick="libraryNextPage()" class="btn btn-secondary">Next ›</button>
|
||||
<button onclick="libraryGoToLastPage()" class="btn btn-secondary">Last »</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,489 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - Web Player</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 WEB PLAYER</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/">← Back to Main</a>
|
||||
<a href="/asteroid/admin">Admin Dashboard</a>
|
||||
</div>
|
||||
|
||||
<!-- Live Stream Section -->
|
||||
<div class="player-section">
|
||||
<h2>🔴 Live Radio Stream</h2>
|
||||
<div class="live-player">
|
||||
<p><strong>Now Playing:</strong> <span id="live-now-playing">Loading...</span></p>
|
||||
<p><strong>Listeners:</strong> <span id="live-listeners">0</span></p>
|
||||
<!-- Stream Quality Selector -->
|
||||
<div style="margin: 10px 0;">
|
||||
<label for="live-stream-quality"><strong>Quality:</strong></label>
|
||||
<select id="live-stream-quality" onchange="changeLiveStreamQuality()" style="margin-left: 10px; padding: 5px;">
|
||||
<option value="aac">AAC 96kbps (Recommended)</option>
|
||||
<option value="mp3">MP3 128kbps (Compatible)</option>
|
||||
<option value="low">MP3 64kbps (Low Bandwidth)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<audio id="live-stream-audio" controls style="width: 80%; margin: 10px 0;">
|
||||
<source id="live-stream-source" src="http://localhost:8000/asteroid.aac" type="audio/aac">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<p><em>Listen to the live Asteroid Radio stream</em></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track Browser -->
|
||||
<div class="player-section">
|
||||
<h2>Personal 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>
|
||||
|
||||
<!-- 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
|
||||
if (audioPlayer) {
|
||||
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('/asteroid/api/tracks');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
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;
|
||||
if (audioPlayer) {
|
||||
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();
|
||||
|
||||
// Stream quality configuration (same as front page)
|
||||
const liveStreamConfig = {
|
||||
aac: {
|
||||
url: 'http://localhost:8000/asteroid.aac',
|
||||
type: 'audio/aac',
|
||||
mount: 'asteroid.aac'
|
||||
},
|
||||
mp3: {
|
||||
url: 'http://localhost:8000/asteroid.mp3',
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid.mp3'
|
||||
},
|
||||
low: {
|
||||
url: 'http://localhost:8000/asteroid-low.mp3',
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid-low.mp3'
|
||||
}
|
||||
};
|
||||
|
||||
// Change live stream quality
|
||||
function changeLiveStreamQuality() {
|
||||
const selector = document.getElementById('live-stream-quality');
|
||||
const config = liveStreamConfig[selector.value];
|
||||
|
||||
// Update audio player
|
||||
const audioElement = document.getElementById('live-stream-audio');
|
||||
const sourceElement = document.getElementById('live-stream-source');
|
||||
|
||||
const wasPlaying = !audioElement.paused;
|
||||
|
||||
sourceElement.src = config.url;
|
||||
sourceElement.type = config.type;
|
||||
audioElement.load();
|
||||
|
||||
// Resume playback if it was playing
|
||||
if (wasPlaying) {
|
||||
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
}
|
||||
}
|
||||
|
||||
// Live stream functionality
|
||||
function updateLiveStream() {
|
||||
try {
|
||||
fetch('/asteroid/api/icecast-status')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Live stream data:', data); // Debug log
|
||||
|
||||
if (data.icestats && data.icestats.source) {
|
||||
const sources = Array.isArray(data.icestats.source) ? data.icestats.source : [data.icestats.source];
|
||||
const mainStream = sources.find(s => s.listenurl && s.listenurl.includes('asteroid.mp3'));
|
||||
|
||||
if (mainStream && mainStream.title) {
|
||||
const titleParts = mainStream.title.split(' - ');
|
||||
const artist = titleParts.length > 1 ? titleParts[0] : 'Unknown Artist';
|
||||
const track = titleParts.length > 1 ? titleParts.slice(1).join(' - ') : mainStream.title;
|
||||
|
||||
const nowPlayingEl = document.getElementById('live-now-playing');
|
||||
const listenersEl = document.getElementById('live-listeners');
|
||||
|
||||
if (nowPlayingEl) nowPlayingEl.textContent = `${artist} - ${track}`;
|
||||
if (listenersEl) listenersEl.textContent = mainStream.listeners || '0';
|
||||
|
||||
console.log('Updated live stream info:', `${artist} - ${track}`, 'Listeners:', mainStream.listeners);
|
||||
} else {
|
||||
console.log('No main stream found or no title');
|
||||
}
|
||||
} else {
|
||||
console.log('No icestats or source in response');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Live stream fetch error:', error);
|
||||
const nowPlayingEl = document.getElementById('live-now-playing');
|
||||
if (nowPlayingEl) nowPlayingEl.textContent = 'Stream unavailable';
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Live stream update error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update live stream info every 10 seconds
|
||||
setTimeout(updateLiveStream, 1000); // Initial update after 1 second
|
||||
setInterval(updateLiveStream, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - Web Player</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/player.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 WEB PLAYER</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/login" data-show-if-logged-out>Login</a>
|
||||
<a href="/asteroid/register" data-show-if-logged-out>Register</a>
|
||||
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<!-- Live Stream Section -->
|
||||
<div class="player-section">
|
||||
<h2 style="color: #00ff00;">🟢 Live Radio Stream</h2>
|
||||
<div class="live-stream">
|
||||
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
|
||||
<!-- Stream Quality Selector -->
|
||||
<div class="live-stream-quality">
|
||||
<label for="live-stream-quality"><strong>Quality:</strong></label>
|
||||
<select id="live-stream-quality" onchange="changeLiveStreamQuality()">
|
||||
<option value="aac">AAC 96kbps (Recommended)</option>
|
||||
<option value="mp3">MP3 128kbps (Compatible)</option>
|
||||
<option value="low">MP3 64kbps (Low Bandwidth)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<audio id="live-stream-audio" controls style="width: 100%; margin: 10px 0;">
|
||||
<source id="live-stream-source" lquery="(attr :src default-stream-url)" type="audio/aac">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<p><em>Listen to the live Asteroid Radio stream</em></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="now-playing" class="now-playing"></div>
|
||||
|
||||
<!-- Track Browser -->
|
||||
<div class="player-section">
|
||||
<h2>Personal Track Library</h2>
|
||||
<div class="track-browser">
|
||||
<input type="text" id="search-tracks" placeholder="Search tracks..." class="search-input">
|
||||
<select id="library-tracks-per-page" class="sort-select" onchange="changeLibraryTracksPerPage()" style="margin: 10px 0px;">
|
||||
<option value="10">10 per page</option>
|
||||
<option value="20" selected>20 per page</option>
|
||||
<option value="50">50 per page</option>
|
||||
</select>
|
||||
<div id="track-list" class="track-list">
|
||||
<div class="loading">Loading tracks...</div>
|
||||
</div>
|
||||
<!-- Pagination Controls -->
|
||||
<div id="library-pagination-controls" style="display: none; margin-top: 20px; text-align: center;">
|
||||
<button onclick="libraryGoToPage(1)" class="btn btn-secondary">« First</button>
|
||||
<button onclick="libraryPreviousPage()" class="btn btn-secondary">‹ Prev</button>
|
||||
<span id="library-page-info" style="margin: 0 15px; font-weight: bold;">Page 1 of 1</span>
|
||||
<button onclick="libraryNextPage()" class="btn btn-secondary">Next ›</button>
|
||||
<button onclick="libraryGoToLastPage()" class="btn btn-secondary">Last »</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>🎵 Asteroid Radio - Player</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background: #0a0a0a;
|
||||
overflow: hidden;
|
||||
}
|
||||
.popout-container {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.popout-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
.popout-title {
|
||||
font-size: 1.2em;
|
||||
color: #00ff00;
|
||||
}
|
||||
.close-btn {
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: #ff6666;
|
||||
}
|
||||
.now-playing-mini {
|
||||
background: #1a1a1a;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
.track-info-mini {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.track-title-mini {
|
||||
color: #00ff00;
|
||||
font-weight: bold;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.track-artist-mini {
|
||||
color: #4488ff;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.quality-selector {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
.quality-selector label {
|
||||
color: #00ff00;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.quality-selector select {
|
||||
background: #0a0a0a;
|
||||
color: #00ff00;
|
||||
border: 1px solid #2a3441;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
audio {
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.status-mini {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
<script src="/asteroid/static/js/front-page.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="popout-container">
|
||||
<div class="popout-header">
|
||||
<div class="popout-title">🎵 Asteroid Radio</div>
|
||||
<button class="close-btn" onclick="window.close()">✖ Close</button>
|
||||
</div>
|
||||
|
||||
<div class="now-playing-mini">
|
||||
<div class="track-info-mini">
|
||||
<div class="track-title-mini" id="popout-track-title">Loading...</div>
|
||||
<div class="track-artist-mini" id="popout-track-artist">Please wait</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quality-selector">
|
||||
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
|
||||
<label for="popout-stream-quality"><strong>Quality:</strong></label>
|
||||
<select id="popout-stream-quality" onchange="changeStreamQuality()">
|
||||
<option value="aac">AAC 96kbps</option>
|
||||
<option value="mp3">MP3 128kbps</option>
|
||||
<option value="low">MP3 64kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<audio id="live-audio" controls autoplay style="width: 100%;">
|
||||
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<div class="status-mini">
|
||||
<span style="color: #00ff00;">● LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Stream quality configuration for popout
|
||||
function getStreamConfig(streamBaseUrl, encoding) {
|
||||
const config = {
|
||||
aac: {
|
||||
url: `${streamBaseUrl}/asteroid.aac`,
|
||||
format: 'AAC 96kbps Stereo',
|
||||
type: 'audio/aac',
|
||||
mount: 'asteroid.aac'
|
||||
},
|
||||
mp3: {
|
||||
url: `${streamBaseUrl}/asteroid.mp3`,
|
||||
format: 'MP3 128kbps Stereo',
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid.mp3'
|
||||
},
|
||||
low: {
|
||||
url: `${streamBaseUrl}/asteroid-low.mp3`,
|
||||
format: 'MP3 64kbps Stereo',
|
||||
type: 'audio/mpeg',
|
||||
mount: 'asteroid-low.mp3'
|
||||
}
|
||||
};
|
||||
return config[encoding];
|
||||
}
|
||||
|
||||
// Change stream quality in popout
|
||||
function changeStreamQuality() {
|
||||
const selector = document.getElementById('popout-stream-quality');
|
||||
const streamBaseUrl = document.getElementById('stream-base-url');
|
||||
const config = getStreamConfig(streamBaseUrl.value, selector.value);
|
||||
|
||||
// Update audio player
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
const sourceElement = document.getElementById('audio-source');
|
||||
|
||||
const wasPlaying = !audioElement.paused;
|
||||
|
||||
sourceElement.src = config.url;
|
||||
sourceElement.type = config.type;
|
||||
audioElement.load();
|
||||
|
||||
// Resume playback if it was playing
|
||||
if (wasPlaying) {
|
||||
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
}
|
||||
}
|
||||
|
||||
// Update now playing info for popout
|
||||
async function updatePopoutNowPlaying() {
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
const html = await response.text();
|
||||
|
||||
// Parse the HTML to extract track info
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const trackText = doc.body.textContent || doc.body.innerText || '';
|
||||
|
||||
// Try to split artist - title format
|
||||
const parts = trackText.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
document.getElementById('popout-track-artist').textContent = parts[0].trim();
|
||||
document.getElementById('popout-track-title').textContent = parts.slice(1).join(' - ').trim();
|
||||
} else {
|
||||
document.getElementById('popout-track-title').textContent = trackText.trim();
|
||||
document.getElementById('popout-track-artist').textContent = 'Asteroid Radio';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating now playing:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update every 10 seconds
|
||||
setInterval(updatePopoutNowPlaying, 10000);
|
||||
// Initial update
|
||||
updatePopoutNowPlaying();
|
||||
|
||||
// Auto-reconnect on stream errors
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.log('Stream error, attempting reconnect in 3 seconds...');
|
||||
setTimeout(function() {
|
||||
audioElement.load();
|
||||
audioElement.play().catch(err => console.log('Reconnect failed:', err));
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('stalled', function() {
|
||||
console.log('Stream stalled, reloading...');
|
||||
audioElement.load();
|
||||
audioElement.play().catch(err => console.log('Reload failed:', err));
|
||||
});
|
||||
|
||||
// Notify parent window that popout is open
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage({ type: 'popout-opened' }, '*');
|
||||
}
|
||||
|
||||
// Notify parent when closing
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage({ type: 'popout-closed' }, '*');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - User Profile</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/profile.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>👤 USER PROFILE</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/player-content" target="content-frame">Player</a>
|
||||
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/logout" class="btn-logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<!-- User Profile Header -->
|
||||
<div class="admin-section">
|
||||
<h2>🎧 User Profile</h2>
|
||||
<div class="profile-info">
|
||||
<div class="info-group">
|
||||
<span class="info-label">Username:</span>
|
||||
<span class="info-value" data-text="username">user</span>
|
||||
</div>
|
||||
<div class="info-group">
|
||||
<span class="info-label">Role:</span>
|
||||
<span class="info-value" data-text="user-role">listener</span>
|
||||
</div>
|
||||
<div class="info-group">
|
||||
<span class="info-label">Member Since:</span>
|
||||
<span class="info-value" data-text="join-date">2024-01-01</span>
|
||||
</div>
|
||||
<div class="info-group">
|
||||
<span class="info-label">Last Active:</span>
|
||||
<span class="info-value" data-text="last-active">Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening Statistics -->
|
||||
<div class="admin-section">
|
||||
<h2>📊 Listening Statistics</h2>
|
||||
<div class="admin-grid">
|
||||
<div class="status-card">
|
||||
<h3>Total Listen Time</h3>
|
||||
<p class="stat-number" data-text="total-listen-time">0h 0m</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Tracks Played</h3>
|
||||
<p class="stat-number" data-text="tracks-played">0</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Sessions</h3>
|
||||
<p class="stat-number" data-text="session-count">0</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Favorite Genre</h3>
|
||||
<p class="stat-text" data-text="favorite-genre">Unknown</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Played Tracks -->
|
||||
<div class="admin-section">
|
||||
<h2>🎵 Recently Played</h2>
|
||||
<div class="tracks-list" id="recent-tracks">
|
||||
<div class="track-item">
|
||||
<div class="track-info">
|
||||
<span class="track-title" data-text="recent-track-1-title">No recent tracks</span>
|
||||
<span class="track-artist" data-text="recent-track-1-artist"></span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span class="track-duration" data-text="recent-track-1-duration"></span>
|
||||
<span class="track-played-at" data-text="recent-track-1-played-at"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-item">
|
||||
<div class="track-info">
|
||||
<span class="track-title" data-text="recent-track-2-title"></span>
|
||||
<span class="track-artist" data-text="recent-track-2-artist"></span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span class="track-duration" data-text="recent-track-2-duration"></span>
|
||||
<span class="track-played-at" data-text="recent-track-2-played-at"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-item">
|
||||
<div class="track-info">
|
||||
<span class="track-title" data-text="recent-track-3-title"></span>
|
||||
<span class="track-artist" data-text="recent-track-3-artist"></span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span class="track-duration" data-text="recent-track-3-duration"></span>
|
||||
<span class="track-played-at" data-text="recent-track-3-played-at"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-actions">
|
||||
<button class="btn btn-secondary" onclick="loadMoreRecentTracks()">Load More</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Artists -->
|
||||
<div class="admin-section">
|
||||
<h2>🎤 Top Artists</h2>
|
||||
<div class="artist-stats">
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-1">Unknown Artist</span>
|
||||
<span class="artist-plays" data-text="top-artist-1-plays">0 plays</span>
|
||||
</div>
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-2"></span>
|
||||
<span class="artist-plays" data-text="top-artist-2-plays"></span>
|
||||
</div>
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-3"></span>
|
||||
<span class="artist-plays" data-text="top-artist-3-plays"></span>
|
||||
</div>
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-4"></span>
|
||||
<span class="artist-plays" data-text="top-artist-4-plays"></span>
|
||||
</div>
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-5"></span>
|
||||
<span class="artist-plays" data-text="top-artist-5-plays"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening Activity Chart -->
|
||||
<div class="admin-section">
|
||||
<h2>📈 Listening Activity</h2>
|
||||
<div class="activity-chart">
|
||||
<p>Activity over the last 30 days</p>
|
||||
<div class="chart-placeholder">
|
||||
<div class="chart-bar" style="height: 20%" data-day="1"></div>
|
||||
<div class="chart-bar" style="height: 45%" data-day="2"></div>
|
||||
<div class="chart-bar" style="height: 30%" data-day="3"></div>
|
||||
<div class="chart-bar" style="height: 60%" data-day="4"></div>
|
||||
<div class="chart-bar" style="height: 80%" data-day="5"></div>
|
||||
<div class="chart-bar" style="height: 25%" data-day="6"></div>
|
||||
<div class="chart-bar" style="height: 40%" data-day="7"></div>
|
||||
<!-- More bars would be generated dynamically -->
|
||||
</div>
|
||||
<p class="chart-note">Listening hours per day</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Actions -->
|
||||
<div class="admin-section">
|
||||
<h2>⚙️ Profile Settings</h2>
|
||||
<div class="profile-actions">
|
||||
<button class="btn btn-primary" onclick="editProfile()">✏️ Edit Profile</button>
|
||||
<button class="btn btn-secondary" onclick="exportListeningData()">📊 Export Data</button>
|
||||
<button class="btn btn-secondary" onclick="clearListeningHistory()">🗑️ Clear History</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Initialize profile page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProfileData();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - Register</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎵 ASTEROID RADIO - REGISTER</h1>
|
||||
<nav class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/player-content" target="content-frame">Player</a>
|
||||
<a href="/asteroid/status" target="content-frame">Status</a>
|
||||
<a href="/asteroid/login">Login</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-form">
|
||||
<h2>Create Account</h2>
|
||||
<div class="message error" data-attr="style" data-attr-value="display-error" style="display: none;">
|
||||
<span data-text="error-message">Registration failed</span>
|
||||
</div>
|
||||
<div class="message success" data-attr="style" data-attr-value="display-success" style="display: none;">
|
||||
<span data-text="success-message">Registration successful!</span>
|
||||
</div>
|
||||
<form method="post" action="/asteroid/register">
|
||||
<div class="form-group">
|
||||
<label>Username:</label>
|
||||
<input type="text" name="username" required minlength="3" maxlength="50">
|
||||
<small style="color: #8892b0;">Minimum 3 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email:</label>
|
||||
<input type="email" name="email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password:</label>
|
||||
<input type="password" name="password" required minlength="6">
|
||||
<small style="color: #8892b0;">Minimum 6 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Confirm Password:</label>
|
||||
<input type="password" name="confirm-password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%;">CREATE ACCOUNT</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="auth-footer">
|
||||
<p>Already have an account? <a href="/asteroid/login">Login here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - User Management</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/users.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>👥 USER MANAGEMENT</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/admin" target="content-frame">Admin</a>
|
||||
<a href="/asteroid/logout" class="btn-logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<!-- User Statistics -->
|
||||
<div class="admin-section">
|
||||
<h2>User Statistics</h2>
|
||||
<div class="user-stats" id="user-stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="total-users">0</span>
|
||||
<span class="stat-label">Total Users</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="active-users">0</span>
|
||||
<span class="stat-label">Active Users</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="admin-users">0</span>
|
||||
<span class="stat-label">Admins</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="dj-users">0</span>
|
||||
<span class="stat-label">DJs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management Actions -->
|
||||
<div class="admin-section">
|
||||
<h2>User Actions</h2>
|
||||
<div class="controls">
|
||||
<button class="btn btn-primary" onclick="loadUsers()">👥 View All Users</button>
|
||||
<button class="btn btn-success" onclick="toggleCreateUserForm()">➕ Create New User</button>
|
||||
<button class="btn btn-secondary" onclick="refreshStats()">🔄 Refresh Stats</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Form (hidden by default) -->
|
||||
<div class="admin-section" id="create-user-form" style="display: none;">
|
||||
<h2>Create New User</h2>
|
||||
<form onsubmit="createNewUser(event)">
|
||||
<div class="form-group">
|
||||
<label>Username:</label>
|
||||
<input type="text" id="new-username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email:</label>
|
||||
<input type="email" id="new-email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password:</label>
|
||||
<input type="password" id="new-password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role:</label>
|
||||
<select id="new-role">
|
||||
<option value="listener">Listener</option>
|
||||
<option value="dj">DJ</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button type="submit" class="btn btn-success">Create User</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="toggleCreateUserForm()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- User List Container (populated by JavaScript) -->
|
||||
<div class="admin-section" id="users-list-section" style="display: none;">
|
||||
<h2>All Users</h2>
|
||||
<div id="users-container">
|
||||
<!-- Users table will be inserted here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
#!/bin/bash
|
||||
# test-server.sh - Comprehensive test suite for Asteroid Radio server
|
||||
# Tests all API endpoints and core functionality
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
BASE_URL="${ASTEROID_URL:-http://localhost:8080}"
|
||||
API_BASE="${BASE_URL}/api/asteroid"
|
||||
VERBOSE="${VERBOSE:-0}"
|
||||
|
||||
# Test counters
|
||||
TESTS_RUN=0
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
|
||||
# Helper functions
|
||||
print_header() {
|
||||
echo -e "\n${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
echo -e "${BLUE}========================================${NC}\n"
|
||||
}
|
||||
|
||||
print_test() {
|
||||
echo -e "${YELLOW}TEST:${NC} $1"
|
||||
}
|
||||
|
||||
print_pass() {
|
||||
echo -e "${GREEN}✓ PASS:${NC} $1"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
}
|
||||
|
||||
print_fail() {
|
||||
echo -e "${RED}✗ FAIL:${NC} $1"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}INFO:${NC} $1"
|
||||
}
|
||||
|
||||
# Test function wrapper
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
TESTS_RUN=$((TESTS_RUN + 1))
|
||||
print_test "$test_name"
|
||||
}
|
||||
|
||||
# Check if server is running
|
||||
check_server() {
|
||||
print_header "Checking Server Status"
|
||||
run_test "Server is accessible"
|
||||
|
||||
if curl -s --max-time 5 "${BASE_URL}/asteroid/" > /dev/null 2>&1; then
|
||||
print_pass "Server is running at ${BASE_URL}"
|
||||
else
|
||||
print_fail "Server is not accessible at ${BASE_URL}"
|
||||
echo "Please start the server with: ./asteroid"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test API endpoint with JSON response
|
||||
test_api_endpoint() {
|
||||
local endpoint="$1"
|
||||
local description="$2"
|
||||
local expected_field="$3"
|
||||
local method="${4:-GET}"
|
||||
local data="${5:-}"
|
||||
|
||||
run_test "$description"
|
||||
|
||||
local url="${API_BASE}${endpoint}"
|
||||
local response
|
||||
|
||||
if [ "$method" = "POST" ]; then
|
||||
response=$(curl -s -X POST "$url" ${data:+-d "$data"})
|
||||
else
|
||||
response=$(curl -s "$url")
|
||||
fi
|
||||
|
||||
if [ $VERBOSE -eq 1 ]; then
|
||||
echo "Response: $response" | head -c 200
|
||||
echo "..."
|
||||
fi
|
||||
|
||||
# Check if response contains expected field
|
||||
if echo "$response" | grep -q "$expected_field"; then
|
||||
print_pass "$description - Response contains '$expected_field'"
|
||||
return 0
|
||||
else
|
||||
print_fail "$description - Expected field '$expected_field' not found"
|
||||
if [ $VERBOSE -eq 1 ]; then
|
||||
echo "Full response: $response"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test JSON structure
|
||||
test_json_structure() {
|
||||
local endpoint="$1"
|
||||
local description="$2"
|
||||
local jq_query="$3"
|
||||
|
||||
run_test "$description"
|
||||
|
||||
local url="${API_BASE}${endpoint}"
|
||||
local response=$(curl -s "$url")
|
||||
|
||||
# Check if jq is available
|
||||
if ! command -v jq &> /dev/null; then
|
||||
print_info "jq not installed, skipping JSON validation"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if echo "$response" | jq -e "$jq_query" > /dev/null 2>&1; then
|
||||
print_pass "$description"
|
||||
return 0
|
||||
else
|
||||
print_fail "$description"
|
||||
if [ $VERBOSE -eq 1 ]; then
|
||||
echo "Response: $response"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test Status Endpoints
|
||||
test_status_endpoints() {
|
||||
print_header "Testing Status Endpoints"
|
||||
|
||||
test_api_endpoint "/status" \
|
||||
"Server status endpoint" \
|
||||
"asteroid-radio"
|
||||
|
||||
test_api_endpoint "/auth-status" \
|
||||
"Authentication status endpoint" \
|
||||
"loggedIn"
|
||||
|
||||
test_api_endpoint "/icecast-status" \
|
||||
"Icecast status endpoint" \
|
||||
"icestats"
|
||||
}
|
||||
|
||||
# Test Admin Endpoints (requires authentication)
|
||||
test_admin_endpoints() {
|
||||
print_header "Testing Admin Endpoints"
|
||||
|
||||
print_info "Note: Admin endpoints require authentication"
|
||||
|
||||
test_api_endpoint "/admin/tracks" \
|
||||
"Admin tracks listing" \
|
||||
"data"
|
||||
|
||||
# Note: scan-library is POST and modifies state, so we just check it exists
|
||||
run_test "Admin scan-library endpoint exists"
|
||||
local response=$(curl -s -X POST "${API_BASE}/admin/scan-library")
|
||||
if echo "$response" | grep -q "status"; then
|
||||
print_pass "Admin scan-library endpoint responds"
|
||||
else
|
||||
print_fail "Admin scan-library endpoint not responding"
|
||||
fi
|
||||
}
|
||||
|
||||
# Test Track Endpoints
|
||||
test_track_endpoints() {
|
||||
print_header "Testing Track Endpoints"
|
||||
|
||||
test_api_endpoint "/tracks" \
|
||||
"Tracks listing endpoint" \
|
||||
"data"
|
||||
}
|
||||
|
||||
# Test Player Endpoints
|
||||
test_player_endpoints() {
|
||||
print_header "Testing Player Control Endpoints"
|
||||
|
||||
test_api_endpoint "/player/status" \
|
||||
"Player status endpoint" \
|
||||
"player"
|
||||
|
||||
test_api_endpoint "/player/pause" \
|
||||
"Player pause endpoint" \
|
||||
"status"
|
||||
|
||||
test_api_endpoint "/player/stop" \
|
||||
"Player stop endpoint" \
|
||||
"status"
|
||||
|
||||
test_api_endpoint "/player/resume" \
|
||||
"Player resume endpoint" \
|
||||
"status"
|
||||
}
|
||||
|
||||
# Test Playlist Endpoints
|
||||
test_playlist_endpoints() {
|
||||
print_header "Testing Playlist Endpoints"
|
||||
|
||||
test_api_endpoint "/playlists" \
|
||||
"Playlists listing endpoint" \
|
||||
"data"
|
||||
|
||||
# Test playlist creation (requires auth)
|
||||
print_info "Note: Playlist creation requires authentication"
|
||||
}
|
||||
|
||||
# Test Page Endpoints (HTML pages)
|
||||
test_page_endpoints() {
|
||||
print_header "Testing HTML Page Endpoints"
|
||||
|
||||
run_test "Front page loads"
|
||||
if curl -s "${BASE_URL}/asteroid/" | grep -q "ASTEROID RADIO"; then
|
||||
print_pass "Front page loads successfully"
|
||||
else
|
||||
print_fail "Front page not loading"
|
||||
fi
|
||||
|
||||
run_test "Admin page loads"
|
||||
if curl -s "${BASE_URL}/asteroid/admin" | grep -q "ADMIN DASHBOARD"; then
|
||||
print_pass "Admin page loads successfully"
|
||||
else
|
||||
print_fail "Admin page not loading"
|
||||
fi
|
||||
|
||||
run_test "Player page loads"
|
||||
if curl -s "${BASE_URL}/asteroid/player" | grep -q "Web Player"; then
|
||||
print_pass "Player page loads successfully"
|
||||
else
|
||||
print_fail "Player page not loading"
|
||||
fi
|
||||
}
|
||||
|
||||
# Test Static File Serving
|
||||
test_static_files() {
|
||||
print_header "Testing Static File Serving"
|
||||
|
||||
run_test "CSS file loads"
|
||||
if curl -s -I "${BASE_URL}/asteroid/static/asteroid.css" | grep -q "200 OK"; then
|
||||
print_pass "CSS file accessible"
|
||||
else
|
||||
print_fail "CSS file not accessible"
|
||||
fi
|
||||
|
||||
run_test "JavaScript files load"
|
||||
if curl -s -I "${BASE_URL}/asteroid/static/js/player.js" | grep -q "200 OK"; then
|
||||
print_pass "JavaScript files accessible"
|
||||
else
|
||||
print_fail "JavaScript files not accessible"
|
||||
fi
|
||||
}
|
||||
|
||||
# Test API Response Format
|
||||
test_api_format() {
|
||||
print_header "Testing API Response Format"
|
||||
|
||||
run_test "API returns JSON format"
|
||||
local response=$(curl -s "${API_BASE}/status")
|
||||
|
||||
if echo "$response" | grep -q '"status"'; then
|
||||
print_pass "API returns JSON (not S-expressions)"
|
||||
else
|
||||
print_fail "API not returning proper JSON format"
|
||||
if [ $VERBOSE -eq 1 ]; then
|
||||
echo "Response: $response"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Print summary
|
||||
print_summary() {
|
||||
print_header "Test Summary"
|
||||
|
||||
echo "Tests Run: $TESTS_RUN"
|
||||
echo -e "Tests Passed: ${GREEN}$TESTS_PASSED${NC}"
|
||||
echo -e "Tests Failed: ${RED}$TESTS_FAILED${NC}"
|
||||
|
||||
if [ $TESTS_FAILED -eq 0 ]; then
|
||||
echo -e "\n${GREEN}✓ All tests passed!${NC}\n"
|
||||
exit 0
|
||||
else
|
||||
echo -e "\n${RED}✗ Some tests failed${NC}\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main test execution
|
||||
main() {
|
||||
echo -e "${BLUE}"
|
||||
echo "╔═══════════════════════════════════════╗"
|
||||
echo "║ Asteroid Radio Server Test Suite ║"
|
||||
echo "╔═══════════════════════════════════════╗"
|
||||
echo -e "${NC}"
|
||||
|
||||
print_info "Testing server at: ${BASE_URL}"
|
||||
print_info "Verbose mode: ${VERBOSE}"
|
||||
echo ""
|
||||
|
||||
# Run all test suites
|
||||
check_server
|
||||
test_api_format
|
||||
test_status_endpoints
|
||||
test_track_endpoints
|
||||
test_player_endpoints
|
||||
test_playlist_endpoints
|
||||
test_admin_endpoints
|
||||
test_page_endpoints
|
||||
test_static_files
|
||||
|
||||
# Print summary
|
||||
print_summary
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-v|--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
-u|--url)
|
||||
BASE_URL="$2"
|
||||
API_BASE="${BASE_URL}/api/asteroid"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -v, --verbose Enable verbose output"
|
||||
echo " -u, --url URL Set base URL (default: http://localhost:8080)"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " ASTEROID_URL Base URL for the server"
|
||||
echo " VERBOSE Enable verbose output (0 or 1)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Test local server"
|
||||
echo " $0 -v # Verbose mode"
|
||||
echo " $0 -u http://example.com # Test remote server"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use -h or --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Run main
|
||||
main
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
#!/bin/bash
|
||||
# User Management API Test Script
|
||||
|
||||
echo "🧪 Testing Asteroid Radio User Management API"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test 1: Get User Stats
|
||||
echo -e "${BLUE}Test 1: Get User Statistics${NC}"
|
||||
echo "GET /asteroid/api/users/stats"
|
||||
curl -s http://localhost:8080/asteroid/api/users/stats | jq .
|
||||
echo ""
|
||||
|
||||
# Test 2: Get All Users
|
||||
echo -e "${BLUE}Test 2: Get All Users${NC}"
|
||||
echo "GET /asteroid/api/users"
|
||||
curl -s http://localhost:8080/asteroid/api/users | jq .
|
||||
echo ""
|
||||
|
||||
# Test 3: Create New User (requires authentication)
|
||||
echo -e "${BLUE}Test 3: Create New User (will fail without auth)${NC}"
|
||||
echo "POST /asteroid/api/users/create"
|
||||
curl -s -X POST http://localhost:8080/asteroid/api/users/create \
|
||||
-d "username=testuser" \
|
||||
-d "email=test@example.com" \
|
||||
-d "password=testpass123" \
|
||||
-d "role=listener" | jq .
|
||||
echo ""
|
||||
|
||||
# Test 4: Login as admin (to get session for authenticated requests)
|
||||
echo -e "${BLUE}Test 4: Login as Admin${NC}"
|
||||
echo "POST /asteroid/login"
|
||||
COOKIES=$(mktemp)
|
||||
curl -s -c $COOKIES -X POST http://localhost:8080/asteroid/login \
|
||||
-d "username=admin" \
|
||||
-d "password=asteroid123" \
|
||||
-w "\nHTTP Status: %{http_code}\n"
|
||||
echo ""
|
||||
|
||||
# Test 5: Create user with authentication
|
||||
echo -e "${BLUE}Test 5: Create New User (authenticated)${NC}"
|
||||
echo "POST /asteroid/api/users/create (with session)"
|
||||
curl -s -b $COOKIES -X POST http://localhost:8080/asteroid/api/users/create \
|
||||
-d "username=testuser_$(date +%s)" \
|
||||
-d "email=test_$(date +%s)@example.com" \
|
||||
-d "password=testpass123" \
|
||||
-d "role=listener" | jq .
|
||||
echo ""
|
||||
|
||||
# Test 6: Get updated user list
|
||||
echo -e "${BLUE}Test 6: Get Updated User List${NC}"
|
||||
echo "GET /asteroid/api/users"
|
||||
curl -s -b $COOKIES http://localhost:8080/asteroid/api/users | jq '.users | length as $count | "Total users: \($count)"'
|
||||
echo ""
|
||||
|
||||
# Test 7: Update user role (if endpoint exists)
|
||||
echo -e "${BLUE}Test 7: Check Track Count${NC}"
|
||||
echo "GET /admin/tracks"
|
||||
curl -s -b $COOKIES http://localhost:8080/admin/tracks | jq '.tracks | length as $count | "Total tracks: \($count)"'
|
||||
echo ""
|
||||
|
||||
# Cleanup
|
||||
rm -f $COOKIES
|
||||
|
||||
echo -e "${GREEN}✅ API Tests Complete!${NC}"
|
||||
|
|
@ -95,7 +95,23 @@
|
|||
|
||||
(defun verify-password (password hash)
|
||||
"Verify a password against its hash"
|
||||
(string= (hash-password password) hash))
|
||||
(let ((computed-hash (hash-password password)))
|
||||
(format t "Computed hash: ~a~%" computed-hash)
|
||||
(format t "Stored hash: ~a~%" hash)
|
||||
(format t "Match: ~a~%" (string= computed-hash hash))
|
||||
(string= computed-hash hash)))
|
||||
|
||||
(defun reset-user-password (username new-password)
|
||||
"Reset a user's password"
|
||||
(let ((user (find-user-by-username username)))
|
||||
(when user
|
||||
(let ((new-hash (hash-password new-password))
|
||||
(user-id (gethash "_id" user)))
|
||||
(db:update "USERS"
|
||||
(db:query (:= "_id" user-id))
|
||||
`(("password-hash" ,new-hash)))
|
||||
(format t "Password reset for user: ~a~%" username)
|
||||
t))))
|
||||
|
||||
(defun user-has-role-p (user role)
|
||||
"Check if user has the specified role"
|
||||
|
|
@ -121,28 +137,58 @@
|
|||
(format t "Error getting current user: ~a~%" e)
|
||||
nil)))
|
||||
|
||||
(defun require-authentication ()
|
||||
"Require user to be authenticated"
|
||||
(handler-case
|
||||
(unless (session:field "user-id")
|
||||
(radiance:redirect "/asteroid/login"))
|
||||
(error (e)
|
||||
(format t "Authentication error: ~a~%" e)
|
||||
(radiance:redirect "/asteroid/login"))))
|
||||
(defun require-authentication (&key (api nil))
|
||||
"Require user to be authenticated.
|
||||
Returns T if authenticated, NIL if not (after emitting error response).
|
||||
If :api t, returns JSON error (401). Otherwise redirects to login page.
|
||||
Auto-detects API routes if not specified."
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(uri (uri-to-url (radiance:uri *request*) :representation :external))
|
||||
;; Use explicit flag if provided, otherwise auto-detect from URI
|
||||
(is-api-request (if api t (search "/api/" uri))))
|
||||
(format t "Authentication check - User ID: ~a, URI: ~a, Is API: ~a~%"
|
||||
user-id uri (if is-api-request "YES" "NO"))
|
||||
(if user-id
|
||||
t ; Authenticated - return T to continue
|
||||
;; Not authenticated - emit error
|
||||
(if is-api-request
|
||||
;; API request - emit JSON error and return the value from api-output
|
||||
(progn
|
||||
(format t "Authentication failed - returning JSON 401~%")
|
||||
(radiance:api-output
|
||||
'(("error" . "Authentication required"))
|
||||
:status 401
|
||||
:message "You must be logged in to access this resource"))
|
||||
;; Page request - redirect to login (redirect doesn't return)
|
||||
(progn
|
||||
(format t "Authentication failed - redirecting to login~%")
|
||||
(radiance:redirect "/asteroid/login"))))))
|
||||
|
||||
(defun require-role (role)
|
||||
"Require user to have a specific role"
|
||||
(handler-case
|
||||
(let ((current-user (get-current-user)))
|
||||
(defun require-role (role &key (api nil))
|
||||
"Require user to have a specific role.
|
||||
Returns T if authorized, NIL if not (after emitting error response).
|
||||
If :api t, returns JSON error (403). Otherwise redirects to login page.
|
||||
Auto-detects API routes if not specified."
|
||||
(let* ((current-user (get-current-user))
|
||||
(uri (uri-to-url (radiance:uri *request*) :representation :external))
|
||||
;; Use explicit flag if provided, otherwise auto-detect from URI
|
||||
(is-api-request (if api t (search "/api/" uri))))
|
||||
(format t "Current user for role check: ~a~%" (if current-user "FOUND" "NOT FOUND"))
|
||||
(format t "Request URI: ~a, Is API: ~a~%" uri (if is-api-request "YES" "NO"))
|
||||
(when current-user
|
||||
(format t "User has role ~a: ~a~%" role (user-has-role-p current-user role)))
|
||||
(unless (and current-user (user-has-role-p current-user role))
|
||||
(if (and current-user (user-has-role-p current-user role))
|
||||
t ; Authorized - return T to continue
|
||||
;; Not authorized - emit error
|
||||
(if is-api-request
|
||||
;; API request - return NIL (caller will handle JSON error)
|
||||
(progn
|
||||
(format t "Role check failed - authorization denied~%")
|
||||
nil)
|
||||
;; Page request - redirect to login (redirect doesn't return)
|
||||
(progn
|
||||
(format t "Role check failed - redirecting to login~%")
|
||||
(radiance:redirect "/asteroid/login")))
|
||||
(error (e)
|
||||
(format t "Role check error: ~a~%" e)
|
||||
(radiance:redirect "/asteroid/login"))))
|
||||
(radiance:redirect "/asteroid/login"))))))
|
||||
|
||||
(defun update-user-role (user-id new-role)
|
||||
"Update a user's role"
|
||||
|
|
@ -236,12 +282,15 @@
|
|||
;; Fallback to delayed initialization
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(sleep 3) ; Give database more time to initialize
|
||||
(dotimes (a 5)
|
||||
(unless (db:connected-p)
|
||||
(sleep 3)) ; Give database more time to initialize
|
||||
(handler-case
|
||||
(progn
|
||||
(format t "Retrying user management setup...~%")
|
||||
(create-default-admin)
|
||||
(format t "User management initialization complete.~%"))
|
||||
(format t "User management initialization complete.~%")
|
||||
(return))
|
||||
(error (e)
|
||||
(format t "Error initializing user system: ~a~%" e))))
|
||||
(format t "Error initializing user system: ~a~%" e)))))
|
||||
:name "user-init"))))
|
||||
|
|
|
|||
Loading…
Reference in New Issue