Compare commits

...

79 Commits

Author SHA1 Message Date
glenneth 90bb9a1650 refactor: Implement Lispy improvements - templates, strings, and error handling
This commit implements three major refactorings to make the codebase more
idiomatic and maintainable:

1. Template Path Centralization
   - Add *template-directory* parameter and helper functions
   - Replace 11+ instances of repetitive template loading boilerplate
   - New functions: template-path, load-template in template-utils.lisp

2. String Construction with FORMAT
   - Replace concatenate with format for external URLs (Icecast, static files)
   - Maintain Radiance URI handling for internal routes
   - Applied to stream URLs, status endpoints, and API responses

3. Error Handling with Custom Conditions
   - NEW FILE: conditions.lisp with comprehensive error hierarchy
   - Custom conditions: not-found-error, authentication-error,
     authorization-error, validation-error, database-error, asteroid-stream-error
   - Helper macros: with-error-handling, with-db-error-handling
   - Helper functions: signal-not-found, signal-validation-error, etc.
   - Refactored 19 API endpoints and page routes
   - Proper HTTP status codes: 404, 401, 403, 400, 500

Changes:
- conditions.lisp: NEW (180+ lines of error handling infrastructure)
- asteroid.asd: Add conditions.lisp to system components
- asteroid.lisp: Refactor 30+ endpoints, eliminate 200+ lines of boilerplate
- template-utils.lisp: Add centralized template loading helpers
- frontend-partials.lisp: Update template loading and string construction

Net result: -97 lines of code, significantly improved error handling,
more maintainable and idiomatic Common Lisp.

All changes tested and verified:
- Clean build
- All endpoints functional
- Error handling returns proper HTTP codes
- No regressions
2025-11-02 12:05:23 -05:00
Brian O'Reilly 0bb93c53a4 Update template paths in calling code. 2025-11-01 15:19:22 -04:00
Brian O'Reilly bc36a00322 Change the template extension to match clip documentation 2025-11-01 15:04:32 -04:00
Glenn Thompson 637650a5ef docs: Update PROJECT-HISTORY.org with Phase 8 and recent developments
Added Phase 8: Docker Deployment & Documentation (Oct 26 - Nov 1, 2025)
- easilok's Docker containerization work (user init, env vars, Dockerfile)
- Complete Docker deployment documentation
- Documentation updates and refinements
- Cross-distribution package manager support

Updated statistics:
- 213+ total commits (was 205+)
- 2.75 months active development (was 2.5)
- Luis Pereira (easilok) now at 23+ commits

Updated current state to November 2025:
- Complete Docker deployment for streams and application
- Comprehensive documentation overhaul
- Recent achievements section added
- Future work includes live chat and song requests per design.org

Last updated: 2025-11-01
2025-11-01 11:48:27 -04:00
Glenn Thompson fd02e4c1d1 chore: remove obsolete session notes file
SESSION-NOTES-2025-10-12.org is no longer needed
2025-11-01 11:48:27 -04:00
Glenn Thompson 1c85464a5f docs: Fix music directory location, remove Python examples, add package manager notes
- Updated DEVELOPMENT.org: music directory is now asteroid/music/ (not docker/music/)
- Clarified music/ can be a symlink to actual music collection
- Added multiple symlink options for music management
- Removed redundant Python integration examples from API-ENDPOINTS.org
- Removed duplicate Integration Examples section (curl already covered in Testing)
- Added package manager notes to INSTALLATION, DEVELOPMENT, DOCKER-STREAMING, and TESTING
- Notes clarify apt examples can be replaced with dnf, pacman, zypper, apk, etc.
- Maintains clean documentation without cluttering every command
2025-11-01 11:48:27 -04:00
Glenn Thompson f1eb43b325 docs: Comprehensive documentation update for October 2025
- Created PROJECT-HISTORY.org with complete development timeline
- Updated all documentation dates to 2025-10-26
- Added current features: multiple player modes, stream queue control, dynamic URLs
- Updated repository URLs from placeholders to actual GitHub links
- Refreshed feature lists across all docs to reflect current state
- Added PostgreSQL status (configured, ready for migration)
- Updated root README.org with comprehensive current information
- Improved quick start guides and access points
- Enhanced API documentation with complete endpoint list
- Updated all streaming documentation for Docker setup
- Standardized author attribution across all docs
- Incremented docs version to 3.0

All documentation now accurately reflects the current state of the project
with 205+ commits, 3 core contributors, and 2.5 months of active development.
2025-11-01 11:48:27 -04:00
Luis Pereira a458a85823 feat: added documentation on build and deploy docker based asteroid 2025-10-30 19:08:46 -04:00
Luis Pereira ab3acf1279 feat: add docker setup for asteroid app 2025-10-30 19:08:46 -04:00
Luis Pereira c4fd96289b feat: add custom env volume path for stream containers 2025-10-30 19:08:46 -04:00
Luis Pereira 0930fc2c1c fix: retry user initialization 2025-10-30 19:08:46 -04:00
Brian O'Reilly a2ae329d54 Merge branch 'glenneth1-fix/persistent-player-font'
conflict resolution
2025-10-25 12:36:30 -04:00
Glenn Thompson 66e97aaf37 fix: Replace Courier New with VT323 in persistent audio player frame
- Changed font-family from 'Courier New' to 'VT323' in body style
- Updated quality selector dropdown font to VT323
- Updated disable button inline style to use VT323
- Ensures consistent typography across the entire site
- Popout player already inherits VT323 from main stylesheet
2025-10-25 18:32:46 +03:00
glenneth a795680e99 feat: Add hybrid player with frameset and pop-out options
- Add frameset mode with persistent audio player in bottom frame
- Add localStorage preference system for user choice
- Update all page navigation to work in both regular and frameset modes
- Add enable/disable buttons for frameset mode
- Fix redirect loops and template parameter issues
2025-10-22 18:01:48 -04:00
glenneth d8abd9661d feat: Add pop-out player and queue management improvements
- Add pop-out player window (400x300px) with auto-reconnect on stream errors
- Add queue reordering with up/down buttons in admin panel
- Add 'Load Queue from M3U' functionality
- Remove Play/Stream buttons from track management
- Fix Liquidsoap audio quality issues:
  - Remove ReplayGain and compression to prevent pulsing
  - Change reload_mode to 'seconds' to prevent playlist exhaustion
  - Reduce crossfade to 3 seconds
  - Add audio buffering settings for stability
- Add auto-reconnect logic for both front page and pop-out players
2025-10-22 18:01:48 -04:00
glenneth 01f5806959 feat: Add hybrid player with frameset and pop-out options
- Add frameset mode with persistent audio player in bottom frame
- Add localStorage preference system for user choice
- Update all page navigation to work in both regular and frameset modes
- Add enable/disable buttons for frameset mode
- Fix redirect loops and template parameter issues
2025-10-21 21:50:39 +03:00
glenneth 74cd3625f3 feat: Add pop-out player and queue management improvements
- Add pop-out player window (400x300px) with auto-reconnect on stream errors
- Add queue reordering with up/down buttons in admin panel
- Add 'Load Queue from M3U' functionality
- Remove Play/Stream buttons from track management
- Fix Liquidsoap audio quality issues:
  - Remove ReplayGain and compression to prevent pulsing
  - Change reload_mode to 'seconds' to prevent playlist exhaustion
  - Reduce crossfade to 3 seconds
  - Add audio buffering settings for stability
- Add auto-reconnect logic for both front page and pop-out players
2025-10-19 13:52:59 +03:00
glenneth 9721fbbc8a fix: track search missing query variable
- Add missing query variable in filterTracks() function
- Reads value from track-search input field
- Bug was pre-existing, not introduced by refactoring
2025-10-17 21:38:18 -04:00
glenneth b3fd00cb4d refactor: improve code consistency and maintainability
- Add /api/asteroid/partial/now-playing-inline endpoint for inline text
- Refactor admin.js to use server-side partial (removes 23 lines of JSON/XML parsing)
- Fix hardcoded path in convert-to-docker-path to use *music-library-path* variable
- Consistent with front-page and player refactoring from upstream
- Improves portability and reduces client-side JavaScript complexity
2025-10-17 21:38:18 -04:00
Luis Pereira 4d0b54f7d6 feat: move player to use now-playing partial 2025-10-16 19:02:00 -04:00
Luis Pereira f3d012cbc6 feat: move front-page to use now-playing partial 2025-10-16 19:02:00 -04:00
Luis Pereira d0efc89e33 feat: add HTML partial hidration for now-playing 2025-10-16 19:02:00 -04:00
Luis Pereira 136fa2fa74 fix: avoid icecast xml to be shown on frontend when there is no artist 2025-10-15 06:41:07 -04:00
Luis Pereira 6506f7d153 fix: playlist create button wrap on small screens 2025-10-15 06:40:20 -04:00
Luis Pereira a7fe6a73c7 fix: srollbars only visible when required on chrome browsers 2025-10-15 06:40:20 -04:00
glenneth fd7707eb74 Improve player UI and reduce buffering
- Remove aggressive stream reconnect
- Reduce live stream buffering delay
- Clean up debug logging
- Fix playlist loading errors
2025-10-15 06:38:53 -04:00
glenneth 5f78213d92 Improve audio quality and streaming performance
- Add 5-second crossfades between tracks
- Use ReplayGain for consistent volume (removed normalize())
- Add audio compression to prevent clipping
- Liquidsoap watches playlist file and reloads every 5 seconds
- Fallback to random playback when queue is empty
- Fix playlist to play all tracks in order
2025-10-15 06:38:53 -04:00
glenneth d4edb8bfec Add admin UI for stream queue management
- Queue management section with add/remove/clear controls
- Add to Queue button on each track in library browser
- Search tracks and add to queue
- Add 10 random tracks button
- Live stream monitor with Now Playing display
- Toast notifications for user feedback
- Real-time queue updates
2025-10-15 06:38:53 -04:00
glenneth b64d101f8a Add stream queue control system
- New stream-control.lisp for queue management backend
- M3U playlist generation with Docker path mapping
- API endpoints for add/remove/clear/reorder queue
- Fix library scan deduplication
- Add stream control documentation
2025-10-15 06:38:53 -04:00
Luis Pereira 70263fbfbc feat: stream base url as variable on templates 2025-10-12 09:56:08 -04:00
glenneth 91c77206d1 docs: Add session notes for page flow feature implementation
Comprehensive documentation of:
- Session objectives and accomplishments
- All files modified with detailed changes
- Technical implementation details
- Testing results
- Integration notes for team members
2025-10-12 09:47:38 -04:00
glenneth 8f1ce3f149 docs: Mark Page Flow feature as complete in TODO
Feature fully implemented and tested:
- Admin login redirects to /asteroid/admin
- Regular user login redirects to /asteroid/profile
- Front page nav links conditional on auth status and role
- Session persistence working across navigation
- User management fully functional
- Profile page API endpoints implemented
2025-10-12 09:47:38 -04:00
glenneth 5362c86f9f fix: Complete UI fixes for page flow feature
- Fix api-output wrapper handling in all JavaScript files
- Add profile page API endpoints (profile, listening-stats, recent-tracks, top-artists)
- Fix session persistence - auth-ui.js now correctly detects login status
- Fix user stats display - now shows correct counts (3 users, 1 admin)
- Fix View All Users table - properly displays all users
- Handle empty arrays gracefully in profile.js (no errors for missing data)

All UI issues resolved:
✓ User management page fully functional
✓ Session persists across navigation
✓ Profile page loads without errors
✓ Correct nav links shown based on role
✓ Admin sees Admin link, regular users don't
2025-10-12 09:47:38 -04:00
glenneth 4b8a3a064c feat: Implement role-based page flow and user management APIs
Core Features:
- Login redirects based on user role (admin -> /admin, users -> /profile)
- User registration redirects to /profile page
- Convert user management APIs to use define-api (Radiance standard)
- Add user statistics API endpoint
- Add create user API endpoint
- Add list users API endpoint

Authentication & Authorization:
- Update require-role to return proper JSON for API requests
- Fix password verification with debug logging
- Add reset-user-password function for admin use

API Endpoints (using define-api):
- /api/asteroid/users - Get all users (admin only)
- /api/asteroid/user-stats - Get user statistics (admin only)
- /api/asteroid/users/create - Create new user (admin only)

Bug Fixes:
- Fix JavaScript API path for user-stats endpoint
- Remove dependency on non-existent radiance:api-output
- Use api-output for proper JSON responses

Testing:
- Admin login redirects to /asteroid/admin ✓
- Regular user login redirects to /asteroid/profile ✓
- User creation working (testuser created successfully) ✓
- User statistics loading correctly ✓

Known Issues (non-blocking):
- User table display needs UI fixes
- Profile page needs additional API endpoints
- Session persistence on navigation needs investigation
2025-10-12 09:47:38 -04:00
Brian O'Reilly 26c516c25d this is kind of a dead patch. clean up working set. 2025-10-11 13:21:38 -04:00
Brian O'Reilly b61e9c891a dep to run music scan in parallel. 2025-10-11 13:20:42 -04:00
Brian O'Reilly a739edc16f update TODO with some UI items. 2025-10-11 13:20:14 -04:00
glenneth 6e82688959 Update documentation authors to Asteroid Radio Development Team 2025-10-10 16:13:29 +03:00
glenneth 925a624bda Documentation cleanup: remove outdated files, add API docs, update core documentation 2025-10-10 16:13:29 +03:00
glenneth da054c1ab9 Updated changes in api-refactoring 2025-10-10 07:27:07 -04:00
glenneth e126426acd Updated TESTING.org 2025-10-10 07:27:07 -04:00
Luis Pereira 549ba8b38f feat: improved navbar in all pages 2025-10-10 07:26:39 -04:00
Luis Pereira 2b6bad3348 feat: improved nav styling 2025-10-10 07:26:39 -04:00
Brian O'Reilly dff2f52f7b Merge branch 'glenneth1-api-refactoring-only' 2025-10-10 07:24:58 -04:00
Glenn Thompson 82785e1da1 Fix frontend JavaScript to work with define-api endpoints
- Update API paths from /asteroid/api/ to /api/asteroid/ in users.js and profile.js
- Add RADIANCE API wrapper handling for icecast-status responses
- Improve error handling in player.js loadTracks function
- All frontend code now properly handles define-api response format
2025-10-09 17:50:20 -04:00
Glenn Thompson 5bc6f27840 Fix frontend JavaScript to work with define-api endpoints
- Update API paths from /asteroid/api/ to /api/asteroid/ in users.js and profile.js
- Add RADIANCE API wrapper handling for icecast-status responses
- Improve error handling in player.js loadTracks function
- All frontend code now properly handles define-api response format
2025-10-08 22:56:54 -04:00
glenneth 5e33d2aafe Add comprehensive automated test suite
- Created test-server.sh with 25+ automated tests
- Tests all API endpoints, HTML pages, and static files
- Color-coded output with detailed pass/fail reporting
- Verbose mode for debugging
- Added TESTING.org documentation with usage guide
- CI/CD ready for integration into workflows

Test coverage:
- 15 API endpoints (all define-api conversions)
- 5 HTML pages (define-page)
- Static file serving
- JSON format validation
- Authentication and authorization

All tests passing except Icecast (expected - containers not running)
2025-10-08 22:56:54 -04:00
glenneth e0c1eac408 Refactor API endpoints to use Radiance's define-api macro
- Converted 15 API endpoints from define-page to define-api
- Added JSON API format configuration for proper JSON responses
- Updated all frontend JavaScript files to use new API URLs
- Maintained define-page for HTML pages and static file serving
- Added comprehensive documentation of changes

Benefits:
- Framework compliance with Radiance best practices
- Automatic routing at /api/asteroid/<name>
- Clean lambda-list parameter handling
- Built-in browser/API dual usage support
- Proper HTTP status codes for errors

All API endpoints tested and working correctly.
2025-10-08 22:56:54 -04:00
glenneth a77b7768c4 Add comprehensive automated test suite
- Created test-server.sh with 25+ automated tests
- Tests all API endpoints, HTML pages, and static files
- Color-coded output with detailed pass/fail reporting
- Verbose mode for debugging
- Added TESTING.org documentation with usage guide
- CI/CD ready for integration into workflows

Test coverage:
- 15 API endpoints (all define-api conversions)
- 5 HTML pages (define-page)
- Static file serving
- JSON format validation
- Authentication and authorization

All tests passing except Icecast (expected - containers not running)
2025-10-08 05:20:56 +03:00
glenneth 5fcb1a06d5 Refactor API endpoints to use Radiance's define-api macro
- Converted 15 API endpoints from define-page to define-api
- Added JSON API format configuration for proper JSON responses
- Updated all frontend JavaScript files to use new API URLs
- Maintained define-page for HTML pages and static file serving
- Added comprehensive documentation of changes

Benefits:
- Framework compliance with Radiance best practices
- Automatic routing at /api/asteroid/<name>
- Clean lambda-list parameter handling
- Built-in browser/API dual usage support
- Proper HTTP status codes for errors

All API endpoints tested and working correctly.
2025-10-08 05:09:50 +03:00
glenneth 19c984b238 Merge remote-tracking branch 'upstream/main' 2025-10-08 04:34:56 +03:00
glenneth dde8027b5c WORKING: API-aware authentication returns JSON for API routes
 Solution:
- require-authentication returns T on success, api-output value on failure
- Endpoints check result: if T, execute body; else return api-output value
- api-output sets response data which gets returned to client

 Results:
- API routes return JSON errors (not HTML redirects)
- Page routes still redirect to login
- Player page handles auth errors gracefully
- Shows 'Error loading tracks' instead of crashing

 Pattern:
  (let ((auth-result (require-authentication)))
    (if (eq auth-result t)
        ;; authenticated - execute endpoint
        ...
        ;; not authenticated - return api-output value
        auth-result))

Thanks to easilokx for the guidance on Radiance patterns!
2025-10-07 18:39:49 -04:00
glenneth dff299923e Fix api-output usage: pass structured data with :status and :message
- Remove handler-case that was catching api-output's condition
- Pass alist data structure instead of JSON string
- Use :status and :message keyword arguments as per Radiance docs
- Detection and formatting work correctly
- Issue: api-output doesn't stop execution from helper function
- Need Radiance-specific pattern (redirect works, api-output doesn't)
2025-10-07 18:39:49 -04:00
glenneth 9ec7848b47 Add API-aware authentication with auto-detection (needs execution flow fix)
 Working:
- Auto-detects API requests from /api/ in URI
- Optional :api keyword parameter for explicit control
- Returns JSON for API requests, redirects for pages
- Page redirects work perfectly (admin page redirects to login)
- API detection logs show correct behavior

 Issue:
- API endpoints still execute after require-authentication returns JSON
- radiance:api-output doesn't stop execution like redirect does
- Need proper Radiance mechanism to halt request processing

Question for easilokx:
What's the correct way to stop execution and return JSON from a helper
function like require-authentication? We tried api-output but execution
continues. How does redirect actually stop execution?
2025-10-07 18:39:49 -04:00
glenneth 707e7aba96 WIP: Add API-aware authentication (detection works, need to fix execution flow)
- Add api-auth-error condition for API authentication failures
- Update require-authentication and require-role to detect /api/ routes
- Add :api keyword parameter for explicit API/page mode
- Auto-detects API requests from URI path
- Returns JSON for API requests, redirects for page requests
- Issue: Execution continues after returning JSON, need Radiance-specific solution
2025-10-07 18:39:49 -04:00
glenneth efb96f950e Fix auth form styling: wider forms (600px) and hidden message boxes
- Increased auth-form max-width from 400px to 600px to match site styling
- Reduced form-group margin-bottom from 3rem to 1.5rem for better spacing
- Added display:none to error/success message boxes by default
- Message boxes now only show when server sets display-error/display-success
2025-10-07 18:39:49 -04:00
glenneth 5d31763e85 Add user registration and authentication UI improvements
- Add registration page with form validation
- Add login/register/logout navigation with conditional display
- Add auth-status API endpoint for session checking
- Add auth-ui.js for dynamic nav based on login state
- Update navigation across all pages (front, admin, profile, player)
- Style logout button with subtle red color
- Auto-login after successful registration
2025-10-07 18:39:49 -04:00
glenneth fa1de1c874 Update profile page to match site-wide layout and styling
- Remove narrow profile-container (600px) in favor of standard container (1200px)
- Replace profile-card divs with admin-section to match admin page styling
- Add consistent h1 header matching other pages
- Remove unused profile-container and profile-card CSS classes
- Keep profile-specific utility styles (profile-info, artist-stats, etc.)
2025-10-07 18:39:49 -04:00
glenneth f7bc30f18c Add user profile page with edit functionality 2025-10-07 18:39:49 -04:00
glenneth 1b1445e25f Add user profile page with clip template styling
- Created profile.chtml template with listening statistics, recent tracks, and top artists
- Added profile.js for dynamic data loading and user interactions
- Extended LASS styles for profile-specific elements (artist stats, track items, activity charts)
- Implemented /profile route with authentication and template rendering
- Added profile API endpoints for user data, stats, recent tracks, and top artists
- Added profile link to main navigation
- Includes placeholder functionality for future listening metrics implementation
2025-10-07 18:39:49 -04:00
Glenn Thompson b31800a7db Fix playlist schema mismatch - use track-ids field consistently
- Fixed field name mismatch: schema uses 'track-ids' not 'tracks'
- Handle Radiance DB storing text fields as lists
- Parse/format comma-separated track IDs properly
- Code is now correct but db:update still doesn't persist (i-lambdalite limitation)
- Requires PostgreSQL for full functionality
2025-10-07 18:39:49 -04:00
Glenn Thompson 8af85afe0e Fix playlist schema mismatch - use track-ids field consistently
- Fixed field name mismatch: schema uses 'track-ids' not 'tracks'
- Handle Radiance DB storing text fields as lists
- Parse/format comma-separated track IDs properly
- Code is now correct but db:update still doesn't persist (i-lambdalite limitation)
- Requires PostgreSQL for full functionality
2025-10-06 04:38:03 +03:00
Luis Pereira ba13777206 fix: use nav styles on front page 2025-10-05 20:48:58 -04:00
Luis Pereira d12fde7248 fix: some sizing of live player 2025-10-05 20:48:58 -04:00
Luis Pereira 82071bb18d fix: pseudo selectors on lass are now working 2025-10-05 20:48:58 -04:00
Luis Pereira d87c1be373 fix: lass rules moved up one level
Some rules were one level deeper and being created inside the btn rule
2025-10-05 20:48:58 -04:00
Luis Pereira d0649a611e fix: move font import to lass file 2025-10-05 20:48:58 -04:00
Luis Pereira 8589b774ed fix: add user management page 2025-10-04 16:10:50 -04:00
Luis Pereira c2e7cfe943 feat: move users javascript code to own file 2025-10-04 16:10:50 -04:00
Luis Pereira bd1b993a03 feat: move player javascript code to own file 2025-10-04 16:10:50 -04:00
Luis Pereira 6edbb5754b feat: move frontpage javascript code to own file 2025-10-04 16:10:50 -04:00
Luis Pereira 35f4c6516b feat: move admin javascript code to own file
Some html element ids and api routes are also fixed here.
2025-10-04 16:10:50 -04:00
Brian O'Reilly cd3c2347ec but, you know, include all the things done. 2025-10-04 14:14:15 -04:00
Brian O'Reilly 14b6904cf5 we provisioned b612.asteroid.radio, update the TODO.org file. 2025-10-04 14:11:44 -04:00
Brian O'Reilly 439da74bb3 remove duplicated code. 2025-10-04 10:31:46 -04:00
Glenn Thompson 803555b8b1 Complete Templates section: CLIP refactoring, user management, pagination, playlists, UI fixes, PostgreSQL setup
 CLIP Template Refactoring:
- Centralized template rendering in template-utils.lisp
- Template caching for performance
- Eliminated code duplication

 User Management:
- Dedicated /admin/users page
- User creation, roles, activation
- Comprehensive API endpoints
- Full test suite

 Track Pagination:
- Admin dashboard: 10/20/50/100 per page
- Web player: 10/20/50 per page
- Smart navigation controls

⚠️ Playlist System (PARTIAL):
- Create empty playlists 
- View playlists 
- Save/load playlists  (database UPDATE fails)
- Audio playback fixed 
- Database limitations documented

 PostgreSQL Setup:
- Docker container configuration
- Complete database schema
- Persistent storage
- Radiance configuration
- Ready for Fade to integrate

 Streaming Infrastructure:
- All 3 streams working (MP3 128k, AAC 96k, MP3 64k)
- Fixed AAC stream (Docker caching issue)
- NAS music mount configured

 UI Fixes:
- Green live stream indicators
- Correct stream quality display
- Now Playing verified working
- Missing API endpoints added

📚 Documentation:
- 6 comprehensive org files
- Complete technical documentation
- Known issues documented

Note: Playlist editing requires PostgreSQL migration (Fade's task)
2025-10-04 09:54:04 -04:00
Glenn Thompson ab7a7c47b5 Complete CLIP template refactoring and all template features
 CLIP Template System:
- Created template-utils.lisp with centralized rendering
- Template caching for performance
- render-template-with-plist for consistent API
- Proper CLIP attribute processors (data-text)
- Documentation in docs/CLIP-REFACTORING.org

 Admin Dashboard Complete:
- System Status: All 4 indicators working (Server, DB, Liquidsoap, Icecast)
- Music Library: Scan, upload, duplicate detection working
- Track Management: Pagination (20/page, configurable 10/20/50/100)
- Player Control: HTML5 audio player with play/pause/stop
- User Management: Moved to separate /admin/users page

 User Management:
- New /admin/users route with dedicated page
- Inline user creation form
- User stats dashboard
- Role management (listener/DJ/admin)
- Activate/deactivate users
- API endpoint /api/users/create
- Tested with curl - all working

 Live Stream & Now Playing:
- Fixed: Green 🟢 LIVE STREAM indicator (was red)
- Fixed: Stream quality display matches selected stream (AAC/MP3)
- Now Playing updates every 10s from Icecast
- No HTML rendering bugs - working correctly

 Track Library:
- Fixed recursive directory scanning bug
- 64 tracks scanned and in database
- Pagination working perfectly

 Front Page & Web Player:
- Station Status shows correct stream quality
- Quality selector updates all displays
- Live stream indicators green
- Now Playing working on all pages

All Templates section items complete [4/4] 
2025-10-04 09:54:04 -04:00
Glenn Thompson b39b54adcb feat: Add live Icecast/Liquidsoap status checks to admin dashboard
- Implement check-icecast-status() to query Icecast API
- Implement check-liquidsoap-status() to check Docker container status
- Update admin page to show real-time streaming infrastructure status
- Note: Auto-scan on startup deferred due to database timing issues
2025-10-04 09:54:04 -04:00
Glenn Thompson 24feeddfa8 feat: Add auto-scan on startup and live Icecast/Liquidsoap status checks
- Auto-scan music library on startup to load existing tracks
- Add check-icecast-status() function to query Icecast API
- Add check-liquidsoap-status() function to check Docker container
- Update admin dashboard to show real-time streaming status
- Eliminates need to manually copy files from incoming on every restart
2025-10-04 09:54:04 -04:00
71 changed files with 12682 additions and 2385 deletions

8
.dockerignore.asteroid Normal file
View File

@ -0,0 +1,8 @@
docker/
music/
data/
*.org
docker-compose.yml
Dockerfile*
Makefile
.git/

24
.gitignore vendored
View File

@ -17,19 +17,6 @@
*.wx32fsl *.wx32fsl
/slime.lisp /slime.lisp
asteroid 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 *.wma
# Docker music directory - keep folder but ignore music files # Docker music directory - keep folder but ignore music files
@ -56,15 +43,6 @@ docker-compose.yml.backup.*
# Log files # Log files
*.log *.log
logs/ logs/
performance-logs/
# Temporary files # 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

42
Dockerfile.asteroid Normal file
View File

@ -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" ]

View File

@ -1,35 +1,45 @@
#+TITLE: Asteroid Radio - Internet Streaming Implementation #+TITLE: Asteroid Radio - Internet Radio Streaming Platform
#+AUTHOR: Database Implementation Branch #+AUTHOR: Asteroid Radio Development Team
#+DATE: 2025-09-11 #+DATE: 2025-10-26
* Overview * 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 * Key Features
** Live Internet Radio Streaming ** Live Internet Radio Streaming
- Continuous MP3 streaming at 128kbps stereo - Multiple quality streams: 128kbps MP3, 96kbps AAC, 64kbps MP3
- Professional audio processing with crossfading and normalization - Professional audio processing with crossfading and ReplayGain normalization
- Icecast2 streaming server integration - Icecast2 streaming server integration
- Liquidsoap audio pipeline for reliable broadcasting - Liquidsoap audio pipeline for reliable broadcasting
- Stream queue control for curated programming
** Music Library Management ** Music Library Management
- Database-backed track storage with metadata extraction - Database-backed track storage with metadata extraction
- Support for MP3, FLAC, OGG, and WAV formats - Support for MP3, FLAC, OGG, and WAV formats
- Automatic metadata extraction using taglib - Automatic metadata extraction using taglib
- Track search, filtering, and sorting capabilities - Track search, filtering, sorting, and pagination
- Recursive directory scanning
** Web Interface ** Web Interface
- RADIANCE framework with CLIP templating - RADIANCE framework with CLIP templating
- Admin dashboard for library management - Admin dashboard for library and user management
- Web player with HTML5 audio controls - Multiple player modes: inline, pop-out, and persistent frameset
- Live stream integration with embedded player - Live stream integration with embedded player
- Responsive design for desktop and mobile
- Role-based access control (Admin/DJ/Listener)
** Network Broadcasting ** Network Broadcasting
- WSL-compatible networking for internal network access - Dynamic stream URL detection for multi-environment support
- Professional streaming URLs for media players - Professional streaming URLs for media players
- Multi-listener support via Icecast2 - Multi-listener support via Icecast2
- Docker-based deployment for easy setup
* Architecture Changes * Architecture Changes
@ -40,46 +50,82 @@ This branch implements a complete internet radio streaming system for Asteroid R
- Database abstraction layer for track storage - Database abstraction layer for track storage
** Streaming Stack ** Streaming Stack
- *Icecast2*: Streaming server (port 8000) - *Icecast2*: Streaming server (port 8000) - Docker containerized
- *Liquidsoap*: Audio processing and streaming pipeline - *Liquidsoap*: Audio processing and streaming pipeline - Docker containerized
- *RADIANCE*: Web server and API (port 8080) - *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 ** File Structure
#+BEGIN_SRC #+BEGIN_SRC
asteroid/ asteroid/
├── asteroid.lisp # Main server with RADIANCE routes ├── asteroid.lisp # Main server with RADIANCE routes
├── asteroid.asd # System definition with dependencies ├── asteroid.asd # System definition with dependencies
├── asteroid-radio.liq # Liquidsoap streaming configuration ├── stream-control.lisp # Stream queue management
├── playlist.m3u # Generated playlist for streaming ├── user-management.lisp # User administration
├── start-asteroid-radio.sh # Launch script for all services ├── playlist-management.lisp # Playlist operations
├── stop-asteroid-radio.sh # Stop script for all services ├── 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 ├── template/ # CLIP HTML templates
│ ├── front-page.chtml # Main page with live stream │ ├── front-page.chtml # Main page with live stream
│ ├── admin.chtml # Admin dashboard │ ├── admin.chtml # Admin dashboard
│ └── player.chtml # Web player interface │ ├── player.chtml # Web player interface
│ └── users.chtml # User management
├── static/ # CSS and assets ├── static/ # CSS and assets
│ └── asteroid.lass # LASS stylesheet │ └── asteroid.lass # LASS stylesheet
└── music/ # Music library ├── docs/ # Comprehensive documentation
├── incoming/ # Upload staging area │ ├── README.org # Documentation index
└── library/ # Processed music files │ ├── PROJECT-OVERVIEW.org # Architecture overview
│ ├── PROJECT-HISTORY.org # Development timeline
│ ├── INSTALLATION.org # Setup guide
│ └── ... # Additional guides
└── music/ # Music library (local dev)
#+END_SRC #+END_SRC
* Track Upload Workflow * Quick Start
** Current Implementation (Manual Upload) ** Docker Installation (Recommended)
1. *Copy files to staging*: Place MP3/FLAC files in =music/incoming/= #+BEGIN_SRC bash
2. *Access admin panel*: Navigate to =http://[IP]:8080/asteroid/admin= # Clone repository
3. *Process files*: Click "Copy Files from Incoming" button git clone https://github.com/fade/asteroid
4. *Database update*: Files are moved to =music/library/= and metadata extracted cd asteroid/docker
5. *Automatic playlist*: =playlist.m3u= is regenerated for streaming
** File Processing Steps # Start all services
1. Files copied from =music/incoming/= to =music/library/= docker compose up -d
2. Metadata extracted using taglib (title, artist, album, duration, bitrate)
3. Database record created with file path and metadata # Verify streams are working
4. Playlist file updated for Liquidsoap streaming curl -I http://localhost:8000/asteroid.mp3
5. Files immediately available for on-demand streaming 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 ** Supported Formats
- *MP3*: Primary format, best compatibility - *MP3*: Primary format, best compatibility
@ -90,216 +136,259 @@ asteroid/
* Icecast2 Integration * Icecast2 Integration
** Configuration ** Configuration
- *Server*: localhost:8000 - *Server*: localhost:8000 (Docker container)
- *Mount point*: =/asteroid.mp3= - *Mount points*: =/asteroid.mp3=, =/asteroid.aac=, =/asteroid-low.mp3=
- *Password*: =b3l0wz3r0= (configured in Liquidsoap) - *Password*: =H1tn31EhsyLrfRmo= (configured in Docker setup)
- *Format*: MP3 128kbps stereo - *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 #+BEGIN_SRC bash
sudo apt update # Managed via docker-compose
sudo apt install icecast2 cd docker
sudo systemctl enable icecast2 docker compose up -d icecast
sudo systemctl start icecast2
#+END_SRC #+END_SRC
** Stream Access ** Stream Access
- *Direct URL*: =http://[IP]:8000/asteroid.mp3= - *High Quality MP3*: =http://localhost:8000/asteroid.mp3= (128kbps)
- *Admin interface*: =http://[IP]:8000/admin/= - *High Quality AAC*: =http://localhost:8000/asteroid.aac= (96kbps)
- *Statistics*: =http://[IP]:8000/status.xsl= - *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 * Liquidsoap Integration
** Configuration File: =asteroid-radio.liq= ** Docker Configuration
#+BEGIN_SRC liquidsoap Liquidsoap runs in a Docker container with configuration in =docker/asteroid-radio-docker.liq=
#!/usr/bin/liquidsoap
# Set log level for debugging ** Key Features
settings.log.level := 4 - *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 ** Management
radio = playlist(mode="randomize", reload=3600, "/path/to/music/library/")
# Add audio processing
radio = amplify(1.0, radio)
# Fallback with sine wave for debugging
radio = fallback(track_sensitive=false, [radio, sine(440.0)])
# Output to Icecast2
output.icecast(
%mp3(bitrate=128),
host="localhost",
port=8000,
password="b3l0wz3r0",
mount="asteroid.mp3",
name="Asteroid Radio",
description="Music for Hackers - Streaming from the Asteroid",
genre="Electronic/Alternative",
url="http://localhost:8080/asteroid/",
radio
)
#+END_SRC
** Installation (Ubuntu/Debian)
#+BEGIN_SRC bash #+BEGIN_SRC bash
sudo apt update # Start Liquidsoap container
sudo apt install liquidsoap cd docker
docker compose up -d liquidsoap
# View logs
docker compose logs -f liquidsoap
# Restart streaming
docker compose restart liquidsoap
#+END_SRC #+END_SRC
** Features ** Telnet Control
- *Random playlist*: Shuffles music library continuously
- *Auto-reload*: Playlist refreshes every hour
- *Audio processing*: Amplification and normalization
- *Fallback*: Sine tone if no music available (debugging)
- *Metadata*: Station info broadcast to listeners
* Network Access
** Local Development
- *Web Interface*: =http://localhost:8080/asteroid/=
- *Live Stream*: =http://localhost:8000/asteroid.mp3=
- *Admin Panel*: =http://localhost:8080/asteroid/admin=
** WSL Network Access
- *WSL IP*: Check with =ip addr show eth0=
- *Web Interface*: =http://[WSL-IP]:8080/asteroid/=
- *Live Stream*: =http://[WSL-IP]:8000/asteroid.mp3=
** Internal Network Broadcasting
- Services bind to all interfaces (0.0.0.0)
- Accessible from any device on local network
- Compatible with media players (VLC, iTunes, etc.)
* Usage Instructions
** Starting the Radio Station
#+BEGIN_SRC bash #+BEGIN_SRC bash
# Launch all services # Connect to Liquidsoap
./start-asteroid-radio.sh telnet localhost 1234
# Or use netcat for scripting
echo "request.queue" | nc localhost 1234
echo "request.skip" | nc localhost 1234
#+END_SRC #+END_SRC
** Stopping the Radio Station * User Management
#+BEGIN_SRC bash
# Stop all services
./stop-asteroid-radio.sh
#+END_SRC
** Adding Music ** Roles
1. Copy MP3/FLAC files to =music/incoming/= - *Admin*: Full system access, user management, stream control
2. Visit admin panel: =http://[IP]:8080/asteroid/admin= - *DJ*: Content management, playlist creation, library access
3. Click "Copy Files from Incoming" - *Listener*: Basic playback and personal playlists
4. Files are processed and added to streaming playlist
** Listening to the Stream ** Default Credentials
- *Web Browser*: Visit main page for embedded player - Username: =admin=
- *Media Player*: Open =http://[IP]:8000/asteroid.mp3= - Password: =asteroid123=
- *Mobile Apps*: Use internet radio apps with stream URL - ⚠️ 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 * 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 ** Track Management
- =GET /api/tracks= - List all tracks with metadata - =GET /api/asteroid/tracks= - List all tracks
- =GET /tracks/{id}/stream= - Stream individual track - =GET /api/asteroid/admin/tracks= - Admin track listing
- =POST /api/scan-library= - Scan and update music library - =POST /api/asteroid/admin/scan-library= - Scan music library
- =POST /api/copy-files= - Process files from incoming directory
** Player Control ** Player Control
- =POST /api/player/play= - Start playback - =GET /api/asteroid/player/status= - Player status
- =POST /api/player/pause= - Pause playback - =POST /api/asteroid/player/play= - Play track
- =POST /api/player/stop= - Stop playback - =POST /api/asteroid/player/pause= - Pause playback
- =GET /api/status= - Get server status - =POST /api/asteroid/player/stop= - Stop playback
- =POST /api/asteroid/player/resume= - Resume playback
** Search and Filter ** Playlist Management
- =GET /api/tracks?search={query}= - Search tracks - =GET /api/asteroid/playlists= - List user playlists
- =GET /api/tracks?sort={field}= - Sort by field - =POST /api/asteroid/playlists/create= - Create playlist
- =GET /api/tracks?artist={name}= - Filter by artist - =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 See =docs/API-ENDPOINTS.org= for complete API documentation.
#+BEGIN_SRC lisp
(db:create "tracks" '((title :text)
(artist :text)
(album :text)
(duration :integer)
(file-path :text)
(format :text)
(bitrate :integer)
(added-date :integer)
(play-count :integer)))
#+END_SRC
** Playlists Collection (Future) * Database
#+BEGIN_SRC lisp
(db:create "playlists" '((name :text) ** Current: Radiance DB
(description :text) - File-based database abstraction
(created-date :integer) - Tracks, users, playlists, sessions
(track-ids :text))) - Suitable for development and small deployments
#+END_SRC
** 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 * Dependencies
** Lisp Dependencies (asteroid.asd) ** Lisp Dependencies
- =:radiance= - Web framework - =radiance= - Web framework
- =:r-clip= - Templating system - =r-clip= - CLIP templating
- =:lass= - CSS generation - =lass= - CSS preprocessing
- =:cl-json= - JSON handling - =cl-json= - JSON handling
- =:alexandria= - Utilities - =alexandria= - Common Lisp utilities
- =:local-time= - Time handling - =local-time= - Time handling
- =taglib= - Audio metadata extraction
** System Dependencies ** System Dependencies (Docker)
- =icecast2= - Streaming server - Docker Engine 20.10+
- =liquidsoap= - Audio processing - Docker Compose 2.0+
- =taglib= - Metadata extraction (via audio-streams) - All streaming components containerized
* Development Notes * Testing
** RADIANCE Configuration ** Automated Test Suite
- Domain: "asteroid" #+BEGIN_SRC bash
- Routes use =#@= syntax for URL patterns # Run comprehensive tests
- Database abstraction via =db:= functions ./test-server.sh
- CLIP templates with =data-text= attributes
** Database Queries # Verbose mode
- Use quoted symbols for field names: =(:= '_id id)= ./test-server.sh -v
- RADIANCE returns hash tables with string keys #+END_SRC
- Primary key is "_id" internally, "id" in JSON responses
** Streaming Considerations ** Test Coverage
- MP3 files with spaces in names require playlist.m3u approach - 25+ automated tests
- Liquidsoap fallback prevents stream silence - API endpoint validation
- Icecast2 mount points must match Liquidsoap configuration - HTML page rendering
- Static file serving
- JSON response format
- Authentication flows
* Future Enhancements * Contributing
** Planned Features ** Development Workflow
- Playlist creation and management interface 1. Fork the repository
- Now-playing status tracking and display 2. Create a feature branch
- Direct browser file uploads with progress 3. Make your changes
- Listener statistics and analytics 4. Run test suite
- Scheduled programming and automation 5. Submit pull request
** Technical Improvements ** Community
- WebSocket integration for real-time updates - *IRC*: #asteroid.music on irc.libera.chat
- Advanced audio processing options - *Issues*: GitHub issue tracker
- Multi-bitrate streaming support - *Discussions*: GitHub discussions
- Mobile-responsive interface enhancements
** Core Team
- Brian O'Reilly (Fade) - Project founder
- Glenn Thompson (glenneth) - Core developer
- Luis Pereira - UI/UX
* Troubleshooting * Troubleshooting
** Common Issues ** Docker Issues
- *No audio in stream*: Check Liquidsoap logs, verify MP3 files #+BEGIN_SRC bash
- *Database errors*: Ensure proper field name quoting in queries # Check container status
- *Network access*: Verify WSL IP and firewall settings docker compose ps
- *File upload issues*: Check permissions on music directories
** Debugging # View logs
- Enable Liquidsoap debug logging: =settings.log.level := 4= docker compose logs icecast
- Check Icecast admin interface for stream status docker compose logs liquidsoap
- Monitor RADIANCE logs for web server issues
- Verify database connectivity and collections # 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 * 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*

View File

@ -1,50 +1,66 @@
* Rundown to Launch. Still to do: * 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] - [ ] parameterize all configuration for runtime loading [0/2]
- [ ] strip hard coded configurations out of the system - [ ] strip hard coded configurations out of the system
- [ ] add configuration template file to the project - [ ] add configuration template file to the project
** [ ] Database [0/2] ** [ ] Database [0/1]
- [ ] PostgresQL [0/3] - [-] PostgresQL [1/3]
- [ ] Add a postgresql docker image to our docker-compose file. - [X] Add a postgresql docker image to our docker-compose file.
- [ ] Configure radiance for postres. - [ ] Configure radiance for postres.
- [ ] Migrate all schema to new database. - [ ] Migrate all schema to new database.
** [ ] Templates: move our template hyrdration into the Clip machinery [0/4] ** [X] Page Flow [2/2] ✅ COMPLETE
- [ ] Admin Dashboard [0/2] - [X] When a user logs in, their user profile page should become the
- [ ] System Status [0/4] root node of the app in their view.
- [ ] Server Status - [X] When the admin user logs in, their view should become the admin
- [ ] Database Status profile page which should have panels for adminstering various
- [ ] Liquidsoap Status aspects of the station.
- [ ] Icecast Status 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).
** [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)
- [ ] Music Library Management [0/2] - [X] Music Library Management [3/3]
- [ ] Add Music Files - [X] Add Music Files (Upload and scan working)
- [ ] Track Management - [X] Track Management (Pagination complete - 20 tracks per page, 4 pages total)
This data needs to be paginated in some way, because the list Pagination implemented with configurable items per page (10/20/50/100).
becomes very long. - [X] Player Control (Play/pause/stop working with HTML5 audio)
- [ ] Player Control
play/pause/edit &etc play/pause/edit &etc
- [ ] User Management - [X] User Management (Moved to separate /admin/users page)
This should be its own page
- [X] Live Stream
- [ ] Live Stream - [X] Now Playing (Working correctly - displays artist and track)
- [ ] Now Playing - [X] Front Page [3/3]
- [ ] Front Page [0/3] - [X] Station Status (Shows live status, listeners, quality)
- [ ] Station Status - [X] Live Stream (Green indicator, quality selector working)
- [ ] Live Stream - [X] Now Playing (Updates every 10s from Icecast, no HTML bugs)
- [ ] Now Playing - [X] Web Player [5/6] ⚠️ MOSTLY COMPLETE (Playlists limited by database)
Now Playing is currently broken on every page. I think this is in - [X] Live Radio Stream (Working with quality selector)
the javascript supporting the feature. Fix here, fix everywhere. - [X] Now Playing (Updates correctly from Icecast)
- [ ] Web Player [0/6] - [X] Personal Track Library (Pagination: 20 tracks/page, search working)
- [ ] Live Radio Stream - [X] Audio Player (Full controls: play/pause/prev/next/shuffle/repeat/volume)
- [ ] Now Playing - [ ] Playlists (PARTIAL - Can create/view, but cannot save/load tracks - requires PostgreSQL)
this currently has a bug where the Now Playing: info card is - [X] Create empty playlists
soing raw HTML which may or may not be coming from liquidSoap. Investigate - [X] View playlists
- [ ] Personal Track Library - [ ] Save queue as playlist (tracks don't persist - db:update fails)
- [ ] Audio Player - [ ] Load playlists (playlists are empty - no tracks saved)
- [ ] Playlists - [ ] Edit playlists (requires PostgreSQL)
- [ ] Play Queue - [X] Play Queue (Add tracks, clear queue - save as playlist blocked by database)

271
analyze-performance.py Normal file
View File

@ -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()

View File

@ -10,6 +10,7 @@
:defsystem-depends-on (:radiance) :defsystem-depends-on (:radiance)
:class "radiance:virtual-module" :class "radiance:virtual-module"
:depends-on (:slynk :depends-on (:slynk
:lparallel
:radiance :radiance
:i-log4cl :i-log4cl
:r-clip :r-clip
@ -32,8 +33,13 @@
:pathname "./" :pathname "./"
:components ((:file "app-utils") :components ((:file "app-utils")
(:file "module") (:file "module")
(:file "conditions")
(:file "database") (:file "database")
(:file "template-utils")
(:file "stream-media") (:file "stream-media")
(:file "user-management") (:file "user-management")
(:file "playlist-management")
(:file "stream-control")
(:file "auth-routes") (:file "auth-routes")
(:file "frontend-partials")
(:file "asteroid"))) (:file "asteroid")))

View File

@ -19,74 +19,232 @@
(merge-pathnames "music/library/" (merge-pathnames "music/library/"
(asdf:system-source-directory :asteroid))) (asdf:system-source-directory :asteroid)))
(defparameter *supported-formats* '("mp3" "flac" "ogg" "wav")) (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 ;; Set JSON as the default API format
;; (defun require-authentication () (setf *default-api-format* "json")
;; "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-role (role) ;; API Routes using Radiance's define-api
;; "Require user to have a specific role" ;; API endpoints are accessed at /api/<name> automatically
;; (handler-case ;; They use lambda-lists for parameters and api-output for responses
;; (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 (define-api asteroid/admin/scan-library () ()
(define-page admin-scan-library #@"/admin/scan-library" ()
"API endpoint to scan music library" "API endpoint to scan music library"
(require-role :admin) (require-role :admin)
(handler-case (with-error-handling
(let ((tracks-added (scan-music-library))) (let ((tracks-added (scan-music-library)))
(setf (radiance:header "Content-Type") "application/json") (api-output `(("status" . "success")
(cl-json:encode-json-to-string ("message" . "Library scan completed")
`(("status" . "success") ("tracks-added" . ,tracks-added))))))
("message" . "Library scan completed")
("tracks-added" . ,tracks-added))))
(error (e)
(setf (radiance:header "Content-Type") "application/json")
(cl-json:encode-json-to-string
`(("status" . "error")
("message" . ,(format nil "Scan failed: ~a" e)))))))
(define-page admin-tracks #@"/admin/tracks" () (define-api asteroid/admin/tracks () ()
"API endpoint to view all tracks in database" "API endpoint to view all tracks in database"
(require-authentication) (require-authentication)
(handler-case (with-error-handling
(let ((tracks (db:select "tracks" (db:query :all)))) (let ((tracks (with-db-error-handling "select"
(setf (radiance:header "Content-Type") "application/json") (db:select "tracks" (db:query :all)))))
(cl-json:encode-json-to-string (api-output `(("status" . "success")
`(("status" . "success") ("tracks" . ,(mapcar (lambda (track)
("tracks" . ,(mapcar (lambda (track) `(("id" . ,(gethash "_id" track))
`(("id" . ,(gethash "_id" track)) ("title" . ,(first (gethash "title" track)))
("title" . ,(first (gethash "title" track))) ("artist" . ,(first (gethash "artist" track)))
("artist" . ,(first (gethash "artist" track))) ("album" . ,(first (gethash "album" track)))
("album" . ,(first (gethash "album" track))) ("duration" . ,(first (gethash "duration" track)))
("duration" . ,(first (gethash "duration" track))) ("format" . ,(first (gethash "format" track)))
("format" . ,(first (gethash "format" track))) ("bitrate" . ,(first (gethash "bitrate" track)))))
("bitrate" . ,(first (gethash "bitrate" track))) tracks)))))))
("play-count" . ,(first (gethash "play-count" track)))))
tracks))))) ;; Playlist API endpoints
(error (e) (define-api asteroid/playlists () ()
(setf (radiance:header "Content-Type") "application/json") "Get all playlists for current user"
(cl-json:encode-json-to-string (require-authentication)
`(("status" . "error") (with-error-handling
("message" . ,(format nil "Failed to retrieve tracks: ~a" e))))))) (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) (defun get-track-by-id (track-id)
"Retrieve track from database by ID" "Get a track by its ID - handles type mismatches"
(let* ((id (if (stringp track-id) (parse-integer track-id) track-id)) ;; Try direct query first
(tracks (db:select "tracks" (db:query (:= '_id id))))) (let ((tracks (db:select "tracks" (db:query (:= "_id" track-id)))))
(when tracks (first tracks)))) (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) (defun get-mime-type-for-format (format)
"Get MIME type for audio format" "Get MIME type for audio format"
@ -99,39 +257,28 @@
(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id)) (define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id))
"Stream audio file by track ID" "Stream audio file by track ID"
(handler-case (with-error-handling
(let* ((id (parse-integer track-id)) (let* ((id (parse-integer track-id))
(track (get-track-by-id id))) (track (get-track-by-id id)))
(if track (unless track
(let* ((file-path (first (gethash "file-path" track))) (signal-not-found "track" id))
(format (first (gethash "format" track))) (let* ((file-path (first (gethash "file-path" track)))
(file (probe-file file-path))) (format (first (gethash "format" track)))
(if file (file (probe-file file-path)))
(progn (unless file
;; Set appropriate headers for audio streaming (error 'not-found-error
(setf (radiance:header "Content-Type") (get-mime-type-for-format format)) :message "Audio file not found on disk"
(setf (radiance:header "Accept-Ranges") "bytes") :resource-type "file"
(setf (radiance:header "Cache-Control") "public, max-age=3600") :resource-id file-path))
;; Increment play count ;; Set appropriate headers for audio streaming
(db:update "tracks" (db:query (:= '_id id)) (setf (radiance:header "Content-Type") (get-mime-type-for-format format))
`(("play-count" ,(1+ (first (gethash "play-count" track)))))) (setf (radiance:header "Accept-Ranges") "bytes")
;; Return file contents (setf (radiance:header "Cache-Control") "public, max-age=3600")
(alexandria:read-file-into-byte-vector file)) ;; Increment play count
(progn (db:update "tracks" (db:query (:= '_id id))
(setf (radiance:header "Content-Type") "application/json") `(("play-count" ,(1+ (first (gethash "play-count" track))))))
(cl-json:encode-json-to-string ;; Return file contents
`(("status" . "error") (alexandria:read-file-into-byte-vector file)))))
("message" . "Audio file not found on disk"))))))
(progn
(setf (radiance:header "Content-Type") "application/json")
(cl-json:encode-json-to-string
`(("status" . "error")
("message" . "Track not found"))))))
(error (e)
(setf (radiance:header "Content-Type") "application/json")
(cl-json:encode-json-to-string
`(("status" . "error")
("message" . ,(format nil "Streaming error: ~a" e)))))))
;; Player state management ;; Player state management
(defvar *current-track* nil "Currently playing track") (defvar *current-track* nil "Currently playing track")
@ -172,150 +319,495 @@
(write-string (generate-css) out)))) (write-string (generate-css) out))))
;; Player control API endpoints ;; Player control API endpoints
(define-page api-play #@"/api/play" () (define-api asteroid/player/play (track-id) ()
"Start playing a track by ID" "Start playing a track by ID"
(setf (radiance:header "Content-Type") "application/json") (with-error-handling
(handler-case (let* ((id (parse-integer track-id))
(let* ((track-id (radiance:get-var "track-id")) (track (get-track-by-id id)))
(id (parse-integer track-id)) (unless track
(track (get-track-by-id id))) (signal-not-found "track" id))
(if track (setf *current-track* id)
(progn (setf *player-state* :playing)
(setf *current-track* id) (setf *current-position* 0)
(setf *player-state* :playing) (api-output `(("status" . "success")
(setf *current-position* 0) ("message" . "Playback started")
(cl-json:encode-json-to-string ("track" . (("id" . ,id)
`(("status" . "success") ("title" . ,(first (gethash "title" track)))
("message" . "Playback started") ("artist" . ,(first (gethash "artist" track)))))
("track" . (("id" . ,id) ("player" . ,(get-player-status)))))))
("title" . ,(first (gethash "title" track)))
("artist" . ,(first (gethash "artist" track)))))
("player" . ,(get-player-status)))))
(cl-json:encode-json-to-string
`(("status" . "error")
("message" . "Track not found")))))
(error (e)
(cl-json:encode-json-to-string
`(("status" . "error")
("message" . ,(format nil "Play error: ~a" e)))))))
(define-page api-pause #@"/api/pause" () (define-api asteroid/player/pause () ()
"Pause current playback" "Pause current playback"
(setf *player-state* :paused) (setf *player-state* :paused)
(setf (radiance:header "Content-Type") "application/json") (api-output `(("status" . "success")
(cl-json:encode-json-to-string ("message" . "Playback paused")
`(("status" . "success") ("player" . ,(get-player-status)))))
("message" . "Playback paused")
("player" . ,(get-player-status)))))
(define-page api-stop #@"/api/stop" () (define-api asteroid/player/stop () ()
"Stop current playback" "Stop current playback"
(setf *player-state* :stopped) (setf *player-state* :stopped)
(setf *current-track* nil) (setf *current-track* nil)
(setf *current-position* 0) (setf *current-position* 0)
(setf (radiance:header "Content-Type") "application/json") (api-output `(("status" . "success")
(cl-json:encode-json-to-string ("message" . "Playback stopped")
`(("status" . "success") ("player" . ,(get-player-status)))))
("message" . "Playback stopped")
("player" . ,(get-player-status)))))
(define-page api-resume #@"/api/resume" () (define-api asteroid/player/resume () ()
"Resume paused playback" "Resume paused playback"
(setf (radiance:header "Content-Type") "application/json")
(if (eq *player-state* :paused) (if (eq *player-state* :paused)
(progn (progn
(setf *player-state* :playing) (setf *player-state* :playing)
(cl-json:encode-json-to-string (api-output `(("status" . "success")
`(("status" . "success") ("message" . "Playback resumed")
("message" . "Playback resumed") ("player" . ,(get-player-status)))))
("player" . ,(get-player-status))))) (api-output `(("status" . "error")
(cl-json:encode-json-to-string ("message" . "Player is not paused"))
`(("status" . "error") :status 400)))
("message" . "Player is not paused")))))
(define-page api-player-status #@"/api/player-status" () (define-api asteroid/player/status () ()
"Get current player status" "Get current player status"
(api-output `(("status" . "success")
("player" . ,(get-player-status)))))
;; 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") (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 (cl-json:encode-json-to-string
`(("status" . "success") `(("status" . "success")
("player" . ,(get-player-status))))) ("stats" . (("total_listen_time" . 0)
("tracks_played" . 0)
("session_count" . 0)
("favorite_genre" . "Unknown"))))))
;; Front page (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 #@"/" () (define-page front-page #@"/" ()
"Main front page" "Main front page"
(let ((template-path (merge-pathnames "template/front-page.chtml" (clip:process-to-string
(asdf:system-source-directory :asteroid)))) (load-template "front-page")
(clip:process-to-string :title "🎵 ASTEROID RADIO 🎵"
(plump:parse (alexandria:read-file-into-string template-path)) :station-name "🎵 ASTEROID RADIO 🎵"
:title "🎵 ASTEROID RADIO 🎵" :status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
:station-name "🎵 ASTEROID RADIO 🎵" :listeners "0"
:status-message "🟢 LIVE - Broadcasting asteroid music for hackers" :stream-quality "128kbps MP3"
:listeners "0" :stream-base-url *stream-base-url*
:stream-quality "128kbps MP3" :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
:now-playing-artist "The Void" :default-stream-encoding "audio/aac"
:now-playing-track "Silence" :default-stream-encoding-desc "AAC 96kbps Stereo"
:now-playing-album "Startup Sounds" :now-playing-artist "The Void"
:now-playing-duration "∞"))) :now-playing-track "Silence"
:now-playing-album "Startup Sounds"
: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 ;; Configure static file serving for other files
(define-page static #@"/static/(.*)" (:uri-groups (path)) (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)))) (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) ;; Admin page (requires authentication)
(define-page admin #@"/admin" () (define-page admin #@"/admin" ()
"Admin dashboard" "Admin dashboard"
(require-authentication) (require-authentication)
(let ((template-path (merge-pathnames "template/admin.chtml" (let ((track-count (handler-case
(asdf:system-source-directory :asteroid)))
(track-count (handler-case
(length (db:select "tracks" (db:query :all))) (length (db:select "tracks" (db:query :all)))
(error () 0)))) (error () 0))))
(clip:process-to-string (clip:process-to-string
(plump:parse (alexandria:read-file-into-string template-path)) (load-template "admin")
:title "🎵 ASTEROID RADIO - Admin Dashboard" :title "🎵 ASTEROID RADIO - Admin Dashboard"
:server-status "🟢 Running" :server-status "🟢 Running"
:database-status (handler-case :database-status (handler-case
(if (db:connected-p) "🟢 Connected" "🔴 Disconnected") (if (db:connected-p) "🟢 Connected" "🔴 Disconnected")
(error () "🔴 No Database Backend")) (error () "🔴 No Database Backend"))
:liquidsoap-status "🔴 Not Running" :liquidsoap-status (check-liquidsoap-status)
:icecast-status "🔴 Not Running" :icecast-status (check-icecast-status)
:track-count (format nil "~d" track-count) :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" () ;; User Management page (requires authentication)
(let ((template-path (merge-pathnames "template/player.chtml" (define-page users-management #@"/admin/user" ()
(asdf:system-source-directory :asteroid)))) "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 (clip:process-to-string
(plump:parse (alexandria:read-file-into-string template-path)) (plump:parse (alexandria:read-file-into-string template-path))
:title "Asteroid Radio - Web Player" :title (format nil "🎧 ~a - Profile | Asteroid Radio" username)
:stream-url "http://localhost:8000/asteroid" :username (or username "Unknown User")
:bitrate "128kbps MP3" :user-role "listener"
:now-playing-artist "The Void" :join-date "Unknown"
:now-playing-track "Silence" :last-active "Unknown"
:now-playing-album "Startup Sounds" :total-listen-time "0h 0m"
:player-status "Stopped"))) :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 "")))
|#
(define-page status-api #@"/status" () ;; Auth status API endpoint
(setf (radiance:header "Content-Type") "application/json") (define-api asteroid/auth-status () ()
(cl-json:encode-json-to-string "Check if user is logged in and their role"
`(("status" . "running") (with-error-handling
("server" . "asteroid-radio") (let* ((user-id (session:field "user-id"))
("version" . "0.1.0") (user (when user-id (find-user-by-id user-id))))
("uptime" . ,(get-universal-time)) (api-output `(("loggedIn" . ,(if user t nil))
("now-playing" . (("title" . "Silence") ("isAdmin" . ,(if (and user (user-has-role-p user :admin)) t nil))
("artist" . "The Void") ("username" . ,(if user
("album" . "Startup Sounds"))) (let ((username (gethash "username" user)))
("listeners" . 0) (if (listp username) (first username) username))
("stream-url" . "http://localhost:8000/asteroid.mp3") nil)))))))
("stream-status" . "live"))))
;; 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-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 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))
("now-playing" . (("title" . "Silence")
("artist" . "The Void")
("album" . "Startup Sounds")))
("listeners" . 0)
("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("stream-status" . "live"))))
;; Live stream status from Icecast ;; Live stream status from Icecast
(define-page icecast-status #@"/api/icecast-status" () (define-api asteroid/icecast-status () ()
"Get live status from Icecast server" "Get live status from Icecast server"
(setf (radiance:header "Content-Type") "application/json") (with-error-handling
(handler-case (let* ((icecast-url (format nil "~a/admin/stats.xml" *stream-base-url*))
(let* ((icecast-url "http://localhost:8000/admin/stats.xml")
(response (drakma:http-request icecast-url (response (drakma:http-request icecast-url
:want-stream nil :want-stream nil
:basic-authorization '("admin" "asteroid_admin_2024")))) :basic-authorization '("admin" "asteroid_admin_2024"))))
@ -331,21 +823,21 @@
(let* ((source-section (subseq xml-string match-start (let* ((source-section (subseq xml-string match-start
(or (cl-ppcre:scan "</source>" xml-string :start match-start) (or (cl-ppcre:scan "</source>" xml-string :start match-start)
(length xml-string)))) (length xml-string))))
(title (or (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown")) (titlep (cl-ppcre:all-matches "<title>" source-section))
(listeners (or (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" source-section "\\1") "0"))) (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 ;; Return JSON in format expected by frontend
(cl-json:encode-json-to-string (api-output
`(("icestats" . (("source" . (("listenurl" . "http://localhost:8000/asteroid.mp3") `(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("title" . ,title) ("title" . ,title)
("listeners" . ,(parse-integer listeners :junk-allowed t))))))))) ("listeners" . ,(parse-integer listeners :junk-allowed t)))))))))
;; No source found, return empty ;; No source found, return empty
(cl-json:encode-json-to-string (api-output
`(("icestats" . (("source" . nil)))))))) `(("icestats" . (("source" . nil))))))))
(cl-json:encode-json-to-string (api-output
`(("error" . "Could not connect to Icecast server"))))) `(("error" . "Could not connect to Icecast server"))
(error (e) :status 503)))))
(cl-json:encode-json-to-string
`(("error" . ,(format nil "Icecast connection failed: ~a" e)))))))
;; RADIANCE server management functions ;; RADIANCE server management functions
@ -392,8 +884,12 @@
(defun -main (&optional args (debug t)) (defun -main (&optional args (debug t))
(declare (ignorable args)) (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 "~&args of asteroid: ~A~%" args)
(format t "~%🎵 ASTEROID RADIO - Music for Hackers 🎵~%") (format t "~%🎵 ASTEROID RADIO - Music for Hackers 🎵~%")
(format t "Using stream server at ~a~%" *stream-base-url*)
(format t "Starting RADIANCE web server...~%") (format t "Starting RADIANCE web server...~%")
(when debug (when debug
(slynk:create-server :port 4009 :dont-close t)) (slynk:create-server :port 4009 :dont-close t))
@ -404,5 +900,8 @@
;; Initialize user management before server starts ;; Initialize user management before server starts
(initialize-user-system) (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)) (run-server))

View File

@ -7,9 +7,7 @@
(define-page login #@"/login" () (define-page login #@"/login" ()
"User login page" "User login page"
(let ((username (radiance:post-var "username")) (let ((username (radiance:post-var "username"))
(password (radiance:post-var "password")) (password (radiance:post-var "password")))
(template-path (merge-pathnames "template/login.chtml"
(asdf:system-source-directory :asteroid))))
(if (and username password) (if (and username password)
;; Handle login form submission ;; Handle login form submission
(let ((user (authenticate-user username password))) (let ((user (authenticate-user username password)))
@ -19,22 +17,28 @@
(format t "Login successful for user: ~a~%" (gethash "username" user)) (format t "Login successful for user: ~a~%" (gethash "username" user))
(handler-case (handler-case
(progn (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) (format t "User ID from DB: ~a~%" user-id)
(setf (session:field "user-id") (if (listp user-id) (first user-id) user-id))) (format t "User role: ~a, redirecting to: ~a~%" user-role redirect-path)
(radiance:redirect "/asteroid/admin")) (setf (session:field "user-id") (if (listp user-id) (first user-id) user-id))
(radiance:redirect redirect-path)))
(error (e) (error (e)
(format t "Session error: ~a~%" e) (format t "Session error: ~a~%" e)
"Login successful but session error occurred"))) "Login successful but session error occurred")))
;; Login failed - show form with error ;; Login failed - show form with error
(clip:process-to-string (render-template-with-plist "login"
(plump:parse (alexandria:read-file-into-string template-path))
:title "Asteroid Radio - Login" :title "Asteroid Radio - Login"
:error-message "Invalid username or password" :error-message "Invalid username or password"
:display-error "display: block;"))) :display-error "display: block;")))
;; Show login form (no POST data) ;; Show login form (no POST data)
(clip:process-to-string (render-template-with-plist "login"
(plump:parse (alexandria:read-file-into-string template-path))
:title "Asteroid Radio - Login" :title "Asteroid Radio - Login"
:error-message "" :error-message ""
:display-error "display: none;")))) :display-error "display: none;"))))
@ -46,41 +50,58 @@
(radiance:redirect "/asteroid/")) (radiance:redirect "/asteroid/"))
;; API: Get all users (admin only) ;; API: Get all users (admin only)
(define-page api-users #@"/api/users" () (define-api asteroid/users () ()
"API endpoint to get all users" "API endpoint to get all users"
(require-role :admin) (require-role :admin)
(setf (radiance:header "Content-Type") "application/json")
(handler-case (handler-case
(let ((users (get-all-users))) (let ((users (get-all-users)))
(cl-json:encode-json-to-string (api-output `(("status" . "success")
`(("status" . "success") ("users" . ,(mapcar (lambda (user)
("users" . ,(mapcar (lambda (user) `(("id" . ,(if (listp (gethash "_id" user))
`(("id" . ,(if (listp (gethash "_id" user)) (first (gethash "_id" user))
(first (gethash "_id" user)) (gethash "_id" user)))
(gethash "_id" user))) ("username" . ,(first (gethash "username" user)))
("username" . ,(first (gethash "username" user))) ("email" . ,(first (gethash "email" user)))
("email" . ,(first (gethash "email" user))) ("role" . ,(first (gethash "role" user)))
("role" . ,(first (gethash "role" user))) ("active" . ,(= (first (gethash "active" user)) 1))
("active" . ,(= (first (gethash "active" user)) 1)) ("created-date" . ,(first (gethash "created-date" user)))
("created-date" . ,(first (gethash "created-date" user))) ("last-login" . ,(first (gethash "last-login" user)))))
("last-login" . ,(first (gethash "last-login" user))))) users)))))
users)))))
(error (e) (error (e)
(cl-json:encode-json-to-string (api-output `(("status" . "error")
`(("status" . "error") ("message" . ,(format nil "Error retrieving users: ~a" e)))
("message" . ,(format nil "Error retrieving users: ~a" e))))))) :status 500))))
;; API: Get user statistics (admin only) ;; 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" "API endpoint to get user statistics"
(require-role :admin) (require-role :admin)
(setf (radiance:header "Content-Type") "application/json")
(handler-case (handler-case
(let ((stats (get-user-stats))) (let ((stats (get-user-stats)))
(cl-json:encode-json-to-string (api-output `(("status" . "success")
`(("status" . "success") ("stats" . ,stats))))
("stats" . ,stats))))
(error (e) (error (e)
(cl-json:encode-json-to-string (api-output `(("status" . "error")
`(("status" . "error") ("message" . ,(format nil "Error retrieving user stats: ~a" e)))
("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))))

263
comprehensive-performance-test.sh Executable file
View File

@ -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"

185
conditions.lisp Normal file
View 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))

View File

@ -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)~%~%")

View File

@ -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#])])]

View File

@ -9,25 +9,43 @@ set("init.allow_root", true)
# Set log level for debugging # Set log level for debugging
log.level.set(4) 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 # Enable telnet server for remote control
settings.server.telnet.set(true) settings.server.telnet.set(true)
settings.server.telnet.port.set(1234) settings.server.telnet.port.set(1234)
settings.server.telnet.bind_addr.set("0.0.0.0") 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( radio = playlist(
mode="randomize", mode="normal", # Play in order (not randomized)
reload=3600, reload=30, # Check for playlist updates every 30 seconds
reload_mode="watch", 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,
"/app/music/" "/app/music/"
) )
# Add some audio processing # Use main playlist, fall back to directory scan
radio = amplify(1.0, radio) radio = fallback(track_sensitive=false, [radio, radio_fallback])
radio = normalize(radio)
# Add crossfade between tracks # Simple crossfade for smooth transitions
radio = crossfade(radio) 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 # Create a fallback with emergency content
emergency = sine(440.0) emergency = sine(440.0)

View File

@ -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

View File

@ -24,12 +24,38 @@ services:
depends_on: depends_on:
- icecast - icecast
volumes: volumes:
- ./music:/app/music:ro - ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
- ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro - ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
- ${QUEUE_PLAYLIST:-../stream-queue.m3u}:/app/stream-queue.m3u:ro
restart: unless-stopped restart: unless-stopped
networks: networks:
- asteroid-network - 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: networks:
asteroid-network: asteroid-network:
driver: bridge driver: bridge
volumes:
postgres-data:
driver: local

120
docker/init-db.sql Normal file
View File

@ -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 $$;

View File

View File

@ -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))

View File

@ -33,12 +33,12 @@ Fade requested setting up Liquidsoap in Docker for the Asteroid Radio project, a
** Check Status ** Check Status
#+BEGIN_SRC bash #+BEGIN_SRC bash
docker-compose ps docker compose ps
#+END_SRC #+END_SRC
** View Logs ** View Logs
#+BEGIN_SRC bash #+BEGIN_SRC bash
docker-compose logs -f docker compose logs -f
#+END_SRC #+END_SRC
** Stop Services ** Stop Services

View File

@ -23,6 +23,7 @@ docker compose ps
echo "" echo ""
echo "🎵 Asteroid Radio is now streaming!" echo "🎵 Asteroid Radio is now streaming!"
echo "📡 High Quality: http://localhost:8000/asteroid.mp3" echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3"
echo "📡 Low Quality: http://localhost:8000/asteroid-low.mp3" echo "📡 High Quality AAC: http://localhost:8000/asteroid.aac"
echo "🔧 Admin Panel: http://localhost:8000/admin/" echo "📡 Low Quality MP3: http://localhost:8000/asteroid-low.mp3"
echo "🔧 Admin Panel: http://localhost:8000/admin/"

439
docs/API-ENDPOINTS.org Normal file
View File

@ -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

197
docs/API-REFERENCE.org Normal file
View File

@ -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]]**.

540
docs/DEVELOPMENT.org Normal file
View File

@ -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/

618
docs/DOCKER-STREAMING.org Normal file
View File

@ -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.

604
docs/INSTALLATION.org Normal file
View File

@ -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.

367
docs/PLAYLIST-SYSTEM.org Normal file
View File

@ -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.

343
docs/POSTGRESQL-SETUP.org Normal file
View File

@ -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

406
docs/PROJECT-HISTORY.org Normal file
View File

@ -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*

124
docs/PROJECT-OVERVIEW.org Normal file
View File

@ -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.

151
docs/README.org Normal file
View File

@ -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*

209
docs/STREAM-CONTROL.org Normal file
View File

@ -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)

275
docs/TESTING.org Normal file
View File

@ -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

View File

@ -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.

View File

@ -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.

68
frontend-partials.lisp Normal file
View File

@ -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")))

135
playlist-management.lisp Normal file
View File

@ -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))))))

57
run-all-tests.sh Executable file
View File

@ -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."

37
setup-environment.lisp Normal file
View File

@ -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)

218
simple-analysis.py Normal file
View File

@ -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()

View File

@ -1,3 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap");
body{ body{
font-family: VT323, monospace; font-family: VT323, monospace;
font-weight: 400; font-weight: 400;
@ -6,6 +8,7 @@ body{
color: #00ffff; color: #00ffff;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
box-sizing: border-box;
} }
body .container{ body .container{
@ -40,20 +43,60 @@ body .panel{
body .nav{ body .nav{
margin: 20px 0; margin: 20px 0;
display: flex;
gap: 5px;
flex-wrap: wrap;
justify-content: center;
} }
body .nav a{ body .nav a{
color: #00ffff; color: #00ffff;
text-decoration: none; text-decoration: none;
margin: 0 15px; margin: 0;
padding: 10px 20px; padding: 10px 20px;
border: 1px solid #2a3441; border: 1px solid #2a3441;
background: #1a2332; background: #1a2332;
min-width: 100px;
text-align: center;
border-sizing: border-box;
letter-spacing: 0.08rem;
cursor: pointer;
display: inline-block; 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; 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{ body .controls{
@ -69,7 +112,7 @@ body .controls button{
cursor: pointer; cursor: pointer;
} }
body .controls button :hover{ body .controls button:hover{
background: #2a3441; background: #2a3441;
} }
@ -82,7 +125,7 @@ body button{
cursor: pointer; cursor: pointer;
} }
body button :hover{ body button:hover{
background: #3a4551; background: #3a4551;
} }
@ -93,6 +136,7 @@ body .now-playing{
margin: 20px 0; margin: 20px 0;
font-size: 1.5em; font-size: 1.5em;
color: #4488ff; color: #4488ff;
overflow: auto;
} }
body .back{ body .back{
@ -102,7 +146,7 @@ body .back{
display: inline-block; display: inline-block;
} }
body .back :hover{ body .back:hover{
text-decoration: underline; text-decoration: underline;
} }
@ -130,6 +174,27 @@ body .player-section{
border-radius: 5px; 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{ body .track-browser{
margin: 15px 0; margin: 15px 0;
} }
@ -143,6 +208,12 @@ body .search-input{
font-family: Courier New, monospace; font-family: Courier New, monospace;
font-size: 14px; font-size: 14px;
margin-bottom: 15px; margin-bottom: 15px;
box-sizing: border-box;
}
body .sort-select{
padding: 0.25rem;
margin-right: 10px;
} }
body .track-list{ body .track-list{
@ -165,10 +236,6 @@ body .track-item{
transition: background-color 0.2s; transition: background-color 0.2s;
} }
body .track-item :hover{
background: #1a2332;
}
body .track-info{ body .track-info{
flex: 1; flex: 1;
} }
@ -276,17 +343,19 @@ body .btn{
transition: all 0.2s; transition: all 0.2s;
} }
body .btn :hover{ body .btn:hover{
background: #3a4551; background: #3a4551;
border-color: #3a4551; border-color: #3a4551;
} }
body .btn .btn-primary{ body .btn .btn-primary{
background: #0066cc; background: #0066cc;
border-color: #0088ff; border-color: #0088ff;
} }
body .btn .btn-primary :hover{ body .btn .btn-primary:hover{
background: #0088ff; background: #0088ff;
} }
@ -295,7 +364,7 @@ body .btn .btn-secondary{
border-color: #2a3441; border-color: #2a3441;
} }
body .btn .btn-secondary :hover{ body .btn .btn-secondary:hover{
background: #666; background: #666;
} }
@ -310,36 +379,37 @@ body .btn .btn.active{
color: #000; color: #000;
} }
body .btn .playlist-controls{ body .playlist-controls{
margin-bottom: 15px; margin-bottom: 15px;
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
} }
body .btn .playlist-input{ body .playlist-input{
flex: 1; flex: 1;
padding: 8px 12px; padding: 8px 12px;
background: #0a0a0a; background: #0a0a0a;
color: #00ffff; color: #00ffff;
border: 1px solid #2a3441; border: 1px solid #2a3441;
font-family: Courier New, monospace; font-family: Courier New, monospace;
box-sizing: border-box;
} }
body .btn .playlist-list{ body .playlist-list{
border: 1px solid #2a3441; border: 1px solid #2a3441;
background: #0a0a0a; background: #0a0a0a;
min-height: 100px; min-height: 100px;
padding: 10px; padding: 10px;
} }
body .btn .queue-controls{ body .queue-controls{
margin-bottom: 15px; margin-bottom: 15px;
display: flex; display: flex;
gap: 10px; gap: 10px;
} }
body .btn .play-queue{ body .play-queue{
border: 1px solid #2a3441; border: 1px solid #2a3441;
background: #0a0a0a; background: #0a0a0a;
min-height: 150px; min-height: 150px;
@ -348,7 +418,7 @@ body .btn .play-queue{
padding: 10px; padding: 10px;
} }
body .btn .queue-item{ body .queue-item{
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -357,46 +427,129 @@ body .btn .queue-item{
margin-bottom: 5px; margin-bottom: 5px;
} }
body .btn .queue-item :last-child{ body .queue-item:last-child{
border-bottom: none; border-bottom: none;
margin-bottom: 0; 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; text-align: center;
color: #666; color: #666;
padding: 20px; padding: 20px;
font-style: italic; font-style: italic;
} }
body .btn .no-tracks{ body .no-tracks{
text-align: center; text-align: center;
color: #666; color: #666;
padding: 20px; padding: 20px;
font-style: italic; font-style: italic;
} }
body .btn .no-playlists{ body .no-playlists{
text-align: center; text-align: center;
color: #666; color: #666;
padding: 20px; padding: 20px;
font-style: italic; font-style: italic;
} }
body .btn .loading{ body .loading{
text-align: center; text-align: center;
color: #4488ff; color: #4488ff;
padding: 20px; padding: 20px;
} }
body .btn .error{ body .error{
text-align: center; text-align: center;
color: #ff0000; color: #ff0000;
padding: 20px; padding: 20px;
font-weight: bold; font-weight: bold;
} }
body .btn .upload-section{ body .upload-section{
margin: 20px 0; margin: 20px 0;
padding: 20px; padding: 20px;
background: #0a0a0a; background: #0a0a0a;
@ -404,19 +557,19 @@ body .btn .upload-section{
border-radius: 5px; border-radius: 5px;
} }
body .btn .upload-controls{ body .upload-controls{
display: flex; display: flex;
gap: 15px; gap: 15px;
align-items: center; align-items: center;
margin-bottom: 15px; margin-bottom: 15px;
} }
body .btn .upload-info{ body .upload-info{
color: #888; color: #888;
font-size: 0.9em; font-size: 0.9em;
} }
body .btn .upload-progress{ body .upload-progress{
margin-top: 10px; margin-top: 10px;
padding: 10px; padding: 10px;
background: #1a2332; background: #1a2332;
@ -424,7 +577,7 @@ body .btn .upload-progress{
border-radius: 3px; border-radius: 3px;
} }
body .btn .progress-bar{ body .progress-bar{
height: 20px; height: 20px;
background: #4488ff; background: #4488ff;
border-radius: 3px; border-radius: 3px;
@ -436,14 +589,14 @@ body .btn .progress-bar{
width: 0%; width: 0%;
} }
body .btn .progress-text{ body .progress-text{
display: block; display: block;
margin-top: 5px; margin-top: 5px;
color: #00ffff; color: #00ffff;
font-size: 0.9em; font-size: 0.9em;
} }
body .btn input{ body input{
padding: 8px 12px; padding: 8px 12px;
background: #1a2332; background: #1a2332;
color: #00ffff; color: #00ffff;
@ -452,7 +605,7 @@ body .btn input{
font-family: Courier New, monospace; font-family: Courier New, monospace;
} }
body .btn .upload-interface{ body .upload-interface{
margin-top: 2rem; margin-top: 2rem;
padding: 1.5rem; padding: 1.5rem;
background-color: #1a2332; background-color: #1a2332;
@ -460,12 +613,12 @@ body .btn .upload-interface{
border: 1px solid #2a3441; border: 1px solid #2a3441;
} }
body .btn .upload-interface h3{ body .upload-interface h3{
color: #00ffff; color: #00ffff;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
body .btn .upload-interface .upload-area{ body .upload-interface .upload-area{
border: 2px dashed #2a3441; border: 2px dashed #2a3441;
border-radius: 8px; border-radius: 8px;
padding: 2rem; padding: 2rem;
@ -478,26 +631,26 @@ body .btn .upload-interface .upload-area{
transition: border-color 0.3s ease; transition: border-color 0.3s ease;
} }
body .btn .upload-interface .upload-area &:hover{ body .upload-interface .upload-area .upload-icon{
border-color: #00ffff;
}
body .btn .upload-interface .upload-area .upload-icon{
font-size: 3rem; font-size: 3rem;
color: #666; color: #666;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
body .btn .upload-interface .upload-area p{ body .upload-interface .upload-area p{
color: #999; color: #999;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
body .btn .upload-interface .upload-area .btn{ body .upload-interface .upload-area .btn{
margin-top: 1rem; margin-top: 1rem;
} }
body .btn .auth-container{ body .upload-interface .upload-area:hover{
border-color: #00ffff;
}
body .auth-container{
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -505,13 +658,13 @@ body .btn .auth-container{
padding: 2rem; padding: 2rem;
} }
body .btn .auth-form{ body .auth-form{
background-color: #1a2332; background-color: #1a2332;
border: 1px solid #2a3441; border: 1px solid #2a3441;
border-radius: 8px; border-radius: 8px;
padding: 2rem; padding: 2rem;
width: 100%; width: 100%;
max-width: 400px; max-width: 600px;
-moz-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); -moz-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
-o-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); -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); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
} }
body .btn .auth-form h2{ body .auth-form h2{
color: #00ffff; color: #00ffff;
text-align: center; text-align: center;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
font-size: 1.5rem; font-size: 1.5rem;
} }
body .btn .auth-form h3{ body .auth-form h3{
color: #00ffff; color: #00ffff;
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 1.2rem; font-size: 1.2rem;
} }
body .btn .form-group{ body .form-group{
margin-bottom: 1rem; margin-bottom: 1.5rem;
} }
body .btn .form-group label{ body .form-group label{
display: block; display: block;
color: #ccc; color: #ccc;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: bold; font-weight: bold;
} }
body .btn .form-group input{ body .form-group input{
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
background-color: #0f0f0f; background-color: #0f0f0f;
@ -554,7 +707,7 @@ body .btn .form-group input{
box-sizing: border-box; box-sizing: border-box;
} }
body .btn .form-group input &:focus{ body .form-group input:focus{
border-color: #00ffff; border-color: #00ffff;
outline: none; outline: none;
-moz-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2); -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); box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
} }
body .btn .form-actions{ body .form-actions{
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
body .btn .message{ body .message{
padding: 0.75rem; padding: 0.75rem;
border-radius: 4px; border-radius: 4px;
margin-top: 1rem; margin-top: 1rem;
@ -578,58 +731,38 @@ body .btn .message{
font-weight: bold; font-weight: bold;
} }
body .btn .message &.success{ body .message.success{
background-color: rgba(0, 255, 0, 0.1); background-color: rgba(0, 255, 0, 0.1);
border: 1px solid #00ffff; border: 1px solid #00ffff;
color: #00ffff; color: #00ffff;
} }
body .btn .message &.error{ body .message.error{
background-color: rgba(255, 0, 0, 0.1); background-color: rgba(255, 0, 0, 0.1);
border: 1px solid #ff0000; border: 1px solid #ff0000;
color: #ff0000; color: #ff0000;
} }
body .btn .auth-link{ body .auth-link{
text-align: center; text-align: center;
margin-top: 1.5rem; margin-top: 1.5rem;
color: #999; color: #999;
} }
body .btn .auth-link a{ body .auth-link a{
color: #00ffff; color: #00ffff;
text-decoration: none; text-decoration: none;
} }
body .btn .auth-link a &:hover{ body .auth-link a:hover{
text-decoration: underline; text-decoration: underline;
} }
body .btn .profile-container{ body .profile-info{
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;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
body .btn .profile-card h2{ body .info-group{
color: #00ffff;
margin-bottom: 1.5rem;
text-align: center;
}
body .btn .profile-info{
margin-bottom: 2rem;
}
body .btn .info-group{
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -637,20 +770,20 @@ body .btn .info-group{
border-bottom: 1px solid #2a3441; border-bottom: 1px solid #2a3441;
} }
body .btn .info-group &:last-child{ body .info-group label{
border-bottom: none;
}
body .btn .info-group label{
color: #ccc; color: #ccc;
font-weight: bold; font-weight: bold;
} }
body .btn .info-group span{ body .info-group span{
color: #fff; color: #fff;
} }
body .btn .role-badge{ body .info-group:last-child{
border-bottom: none;
}
body .role-badge{
background-color: #00ffff; background-color: #00ffff;
color: #000; color: #000;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
@ -659,17 +792,154 @@ body .btn .role-badge{
font-weight: bold; font-weight: bold;
} }
body .btn .profile-actions{ body .profile-actions{
display: flex; display: flex;
gap: 1rem; gap: 1rem;
justify-content: center; 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; margin-top: 2rem;
} }
body .btn .users-table{ body .users-table{
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background-color: #1a2332; background-color: #1a2332;
@ -678,11 +948,11 @@ body .btn .users-table{
overflow: hidden; overflow: hidden;
} }
body .btn .users-table thead{ body .users-table thead{
background-color: #0f0f0f; background-color: #0f0f0f;
} }
body .btn .users-table thead th{ body .users-table thead th{
padding: 1rem; padding: 1rem;
text-align: left; text-align: left;
color: #00ffff; 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; border-bottom: 1px solid #2a3441;
} }
body .btn .users-table tbody tr &:hover{ body .users-table tbody tr td{
background-color: #222;
}
body .btn .users-table tbody tr td{
padding: 1rem; padding: 1rem;
color: #fff; color: #fff;
vertical-align: middle; 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; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
body .btn .users-table tbody .user-actions .btn{ body .users-table tbody .user-actions .btn{
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
body .btn .user-stats{ body .user-stats{
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem; gap: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
body .btn .stat-card{ body .stat-card{
background-color: #1a2332; background-color: #1a2332;
border: 1px solid #2a3441; border: 1px solid #2a3441;
border-radius: 8px; border-radius: 8px;
@ -731,15 +1001,28 @@ body .btn .stat-card{
text-align: center; text-align: center;
} }
body .btn .stat-card .stat-number{ body .stat-card .stat-number{
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
color: #00ffff; color: #00ffff;
display: block; display: block;
} }
body .btn .stat-card .stat-label{ body .stat-card .stat-label{
color: #ccc; color: #ccc;
font-size: 0.875rem; font-size: 0.875rem;
margin-top: 0.5rem; 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;
}
} }

File diff suppressed because it is too large Load Diff

660
static/js/admin.js Normal file
View File

@ -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';
}
}
}

40
static/js/auth-ui.js Normal file
View File

@ -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');
});

198
static/js/front-page.js Normal file
View File

@ -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';
}
});

589
static/js/player.js Normal file
View File

@ -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);

298
static/js/profile.js Normal file
View File

@ -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');
}

214
static/js/users.js Normal file
View File

@ -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);

222
stream-control.lisp Normal file
View File

@ -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)))

View File

@ -13,9 +13,11 @@
(cl-fad:list-directory directory :follow-symlinks nil)))) (cl-fad:list-directory directory :follow-symlinks nil))))
(defun scan-directory-for-music-recursively (path) (defun scan-directory-for-music-recursively (path)
(loop for directory in (uiop:subdirectories path) "Recursively scan directory and all subdirectories for music files"
with music = (scan-directory-for-music path) (let ((files-in-current-dir (scan-directory-for-music path))
appending (scan-directory-for-music directory))) (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) (defun extract-metadata-with-taglib (file-path)
"Extract metadata using taglib library" "Extract metadata using taglib library"
@ -57,45 +59,66 @@
:duration 0 :duration 0
:bitrate 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) (defun insert-track-to-database (metadata)
"Insert track metadata into database" "Insert track metadata into database if it doesn't already exist"
(db:insert "tracks" ;; Ensure tracks collection exists
(list (list "title" (getf metadata :title)) (unless (db:collection-exists-p "tracks")
(list "artist" (getf metadata :artist)) (error "Tracks collection does not exist in database"))
(list "album" (getf metadata :album))
(list "duration" (getf metadata :duration)) ;; Check if track already exists
(list "file-path" (getf metadata :file-path)) (let ((file-path (getf metadata :file-path)))
(list "format" (getf metadata :format)) (if (track-exists-p file-path)
(list "bitrate" (getf metadata :bitrate)) nil
(list "added-date" (local-time:timestamp-to-unix (local-time:now))) (progn
(list "play-count" 0)))) (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" 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)))
t))))
(defun scan-music-library (&optional (directory *music-library-path*)) (defun scan-music-library (&optional (directory *music-library-path*))
"Scan music library directory and add tracks to database" "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)) (let ((audio-files (scan-directory-for-music-recursively directory))
(added-count 0)) (added-count 0)
(skipped-count 0))
(dolist (file audio-files) (dolist (file audio-files)
(let ((metadata (extract-metadata-with-taglib file))) (let ((metadata (extract-metadata-with-taglib file)))
(when metadata (when metadata
(handler-case (handler-case
(progn (if (insert-track-to-database metadata)
(insert-track-to-database metadata) (incf added-count)
(incf added-count) (incf skipped-count))
(format t "Added: ~a~%" (getf metadata :file-path)))
(error (e) (error (e)
(format t "Error adding ~a: ~a~%" file e)))))) (format t "Error adding ~a: ~a~%" file e))))))
(format t "Library scan complete. Added ~a tracks.~%" added-count)
added-count)) added-count))
;; Initialize music directory structure ;; Initialize music directory structure
(defun ensure-music-directories () (defun initialize-music-directories (&optional (base-dir *music-library-path*))
"Create music directory structure if it doesn't exist" "Create necessary music directories if they don't exist"
(let ((base-dir (merge-pathnames "music/" (asdf:system-source-directory :asteroid)))) (progn
(ensure-directories-exist (merge-pathnames "library/" base-dir)) (ensure-directories-exist (merge-pathnames "library/" base-dir))
(ensure-directories-exist (merge-pathnames "incoming/" base-dir)) (ensure-directories-exist (merge-pathnames "incoming/" base-dir))
(ensure-directories-exist (merge-pathnames "temp/" base-dir)) (ensure-directories-exist (merge-pathnames "temp/" base-dir))))
(format t "Music directories initialized at ~a~%" base-dir)))
;; Simple file copy endpoint for manual uploads ;; Simple file copy endpoint for manual uploads
(define-page copy-files #@"/admin/copy-files" () (define-page copy-files #@"/admin/copy-files" ()

1
stream-queue.m3u Normal file
View File

@ -0,0 +1 @@
#EXTM3U

59
template-utils.lisp Normal file
View File

@ -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)))

View File

@ -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>

176
template/admin.ctml Normal file
View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

73
template/front-page.ctml Normal file
View File

@ -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>

View File

@ -8,11 +8,20 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>🎵 ASTEROID RADIO - LOGIN</h1> <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-container">
<div class="auth-form"> <div class="auth-form">
<h2>System Access</h2> <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> <span data-text="error-message">Invalid username or password</span>
</div> </div>
<form method="post" action="/asteroid/login"> <form method="post" action="/asteroid/login">
@ -30,8 +39,8 @@
</form> </form>
<div class="panel" style="margin-top: 20px; text-align: center;"> <div class="panel" style="margin-top: 20px; text-align: center;">
<strong style="color: #ff6600;">Default Admin Credentials:</strong><br> <strong style="color: #ff6600;">Default Admin Credentials:</strong><br>
Username: <code style="color: #00ff00;">admin</code><br> Username: <br><code style="color: #00ff00;">admin</code><br>
Password: <code style="color: #00ff00;">asteroid123</code> Password: <br><code style="color: #00ff00;">asteroid123</code>
</div> </div>
</div> </div>
</div> </div>

View File

@ -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>

View File

@ -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>

View File

@ -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>

137
template/player.ctml Normal file
View File

@ -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>

234
template/popout-player.ctml Normal file
View File

@ -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>

171
template/profile.ctml Normal file
View File

@ -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>

60
template/register.ctml Normal file
View File

@ -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>

92
template/users.ctml Normal file
View File

@ -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>

360
test-server.sh Executable file
View File

@ -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

71
test-user-api.sh Executable file
View File

@ -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}"

View File

@ -95,7 +95,23 @@
(defun verify-password (password hash) (defun verify-password (password hash)
"Verify a password against its 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) (defun user-has-role-p (user role)
"Check if user has the specified role" "Check if user has the specified role"
@ -121,28 +137,58 @@
(format t "Error getting current user: ~a~%" e) (format t "Error getting current user: ~a~%" e)
nil))) nil)))
(defun require-authentication () (defun require-authentication (&key (api nil))
"Require user to be authenticated" "Require user to be authenticated.
(handler-case Returns T if authenticated, NIL if not (after emitting error response).
(unless (session:field "user-id") If :api t, returns JSON error (401). Otherwise redirects to login page.
(radiance:redirect "/asteroid/login")) Auto-detects API routes if not specified."
(error (e) (let* ((user-id (session:field "user-id"))
(format t "Authentication error: ~a~%" e) (uri (uri-to-url (radiance:uri *request*) :representation :external))
(radiance:redirect "/asteroid/login")))) ;; 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) (defun require-role (role &key (api nil))
"Require user to have a specific role" "Require user to have a specific role.
(handler-case Returns T if authorized, NIL if not (after emitting error response).
(let ((current-user (get-current-user))) If :api t, returns JSON error (403). Otherwise redirects to login page.
(format t "Current user for role check: ~a~%" (if current-user "FOUND" "NOT FOUND")) Auto-detects API routes if not specified."
(when current-user (let* ((current-user (get-current-user))
(format t "User has role ~a: ~a~%" role (user-has-role-p current-user role))) (uri (uri-to-url (radiance:uri *request*) :representation :external))
(unless (and current-user (user-has-role-p current-user role)) ;; Use explicit flag if provided, otherwise auto-detect from URI
(format t "Role check failed - redirecting to login~%") (is-api-request (if api t (search "/api/" uri))))
(radiance:redirect "/asteroid/login"))) (format t "Current user for role check: ~a~%" (if current-user "FOUND" "NOT FOUND"))
(error (e) (format t "Request URI: ~a, Is API: ~a~%" uri (if is-api-request "YES" "NO"))
(format t "Role check error: ~a~%" e) (when current-user
(radiance:redirect "/asteroid/login")))) (format t "User has role ~a: ~a~%" role (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"))))))
(defun update-user-role (user-id new-role) (defun update-user-role (user-id new-role)
"Update a user's role" "Update a user's role"
@ -236,12 +282,15 @@
;; Fallback to delayed initialization ;; Fallback to delayed initialization
(bt:make-thread (bt:make-thread
(lambda () (lambda ()
(sleep 3) ; Give database more time to initialize (dotimes (a 5)
(handler-case (unless (db:connected-p)
(progn (sleep 3)) ; Give database more time to initialize
(format t "Retrying user management setup...~%") (handler-case
(create-default-admin) (progn
(format t "User management initialization complete.~%")) (format t "Retrying user management setup...~%")
(error (e) (create-default-admin)
(format t "Error initializing user system: ~a~%" e)))) (format t "User management initialization complete.~%")
(return))
(error (e)
(format t "Error initializing user system: ~a~%" e)))))
:name "user-init")))) :name "user-init"))))