A music streaming server built with FastAPI, FFmpeg, and Redis-backed sessions. Streams audio files from S3/CloudFront using signed URLs and allows authenticated users to control playback. Tracks are registered via CSV and organized into named playlists.
- Python 3.10+
- FFmpeg installed and available in
$PATH - Redis server
- AWS S3 bucket with CloudFront distribution
- CloudFront key pair for signed URLs
# Setup virtual environment
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements.txtSet the following environment variables (or in .env file):
# Session/Auth
SESSION_SECRET=your-signing-secret
REDIS_URL=redis://your-redis-host:6379
# User metadata lookups (pro/admin checks)
DATABASE_URL=postgresql://user:password@db-host:5432/your_database
PG_SSL=true # Optional
PG_SSL_REJECT_UNAUTHORIZED=false # Optional
# Backward-compatible fallback if DATABASE_URL is not set:
PG_DB=your_database
PG_USER=your_user
PG_PW=your_password
PG_HOST=your-db-host
PG_PORT=5432
# CloudFront
CLOUDFRONT_DOMAIN=d1234567890.cloudfront.net
CLOUDFRONT_KEY_ID=KXXXXXXXXXXXXXXX
CLOUDFRONT_PRIVATE_KEY_PATH=./private_frc_cloudfront_key.pemTRACKS_CSV_PATH=tracks.csv # Default: tracks.csv (or Google Sheets URL)
PLAYLISTS_CSV_PATH=playlists.csv # Default: playlists.csv (or Google Sheets URL)
SESSION_COOKIE_NAME=frc_session # Default: frc_session
SESSION_REDIS_PREFIX=frc:sess: # Default: frc:sess:
HOST=0.0.0.0 # Default: 0.0.0.0
PORT=5000 # Default: 5000
CHUNK_SIZE=1024 # Default: 1024
LISTENER_QUEUE_MAXSIZE=256 # Default: 256
IDLE_TIMEOUT=600 # Default: 600 (seconds)
LOGIN_URL=https://example.com/login # Redirect URL for unauthenticated users
PUBLIC_BASE_URL=https://radio.example.com # Optional: forces canonical/sitemap/robots URLsADMIN_EMAILS=admin@example.com,other@example.com # Comma-separated email whitelistDEV_MODE=true # Bypass auth checks for local development
DEV_USER_EMAIL=dev@localhost # Email used for admin checks in dev modeTracks are registered in a CSV file or Google Sheets (set via TRACKS_CSV_PATH).
The CSV should have the following headers:
Track Name,File Name,KEY TITLE,Track Number,Album,Psudo-Tags,Previous Titles
Example row:
Haunting Tavern,haunting_tavern_remst_fullmix.mp3,HAUNTING_TAVERN_REMST_FULLMIX,1,Secrets of Strahd Original Soundtrack,"peaceful, town, village, horror, sos",
- KEY TITLE: Unique identifier used in playlist definitions
- File Name: Filename in S3 (stored at
s3://bucket/audio/{filename})
You can use a Google Sheets URL directly:
TRACKS_CSV_PATH=https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit?gid=0Playlists are loaded from a CSV file or Google Sheets (set via PLAYLISTS_CSV_PATH).
The CSV should have the following columns:
| Playlist Title | Track Key |
|---|---|
| Tavern Ambience | HAUNTING_TAVERN_REMST_FULLMIX |
| Tavern Ambience | TAVERN_BUSTLE |
| Combat Epic | BATTLE_EPIC1 |
| Combat Epic | BATTLE_EPIC2 |
Tracks are grouped by Playlist Title and appended in order.
You can use a Google Sheets URL directly:
PLAYLISTS_CSV_PATH=https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit?gid=0The sheet must be publicly accessible (or "Anyone with the link can view").
Audio files should be stored in S3 with the path prefix /audio/:
s3://your-bucket/
└── audio/
├── haunting_tavern_remst_fullmix.mp3
├── battle_epic_01.mp3
└── ...
CloudFront should be configured with:
- Origin pointing to your S3 bucket
- Signed URL requirement (restricted viewer access)
- Key pair for signing (private key stored locally)
python3 radio.pyOr with uvicorn manually:
uvicorn radio:service.app --reload --host 0.0.0.0 --port 5000Deploys use versioned tarball artifacts copied to the server and activated via a current symlink.
/root/music_stream_server/
├── .env
├── private_frc_cloudfront_key.pem
├── artifacts/
├── releases/
└── current -> /root/music_stream_server/releases/music_stream_server_YYYYMMDDHHMMSS
./scripts/build_artifact.shThis prints the generated artifact path (for example dist/music_stream_server_20260226153000.tar.gz).
./scripts/deploy_artifact.sh dist/music_stream_server_20260226153000.tar.gzBuild + deploy in one step:
./scripts/release.shOptional environment variables:
MUSIC_SERVER=root@your-server
MUSIC_SSH_KEY=~/.ssh/your_key
MUSIC_DEPLOY_ROOT=/root/music_stream_server
MUSIC_SERVICE_NAME=radio.service
MUSIC_KEEP_RELEASES=3Rollback to previous release:
./scripts/rollback_artifact.shRollback to a specific release directory name:
./scripts/rollback_artifact.sh music_stream_server_20260226153000This server reads Express-compatible signed cookies (e.g., s:<value>.<sig>) and validates them using HMAC SHA256.
Session data is loaded from Redis using the key format:
frc:sess:<sid>
frc:sess: is configurable via SESSION_REDIS_PREFIX.
Expected JSON structure:
{
"user": "user_id_value",
"cookie": {}
}Returns the main landing page (index.html).
Returns the listener interface.
Requires login. Shows host controls for managing the specified channel.
Requires login. Returns available playlist names.
Response format:
{
"playlists": ["tavern_ambience", "combat_epic", "exploration"]
}Requires login. Controls playback or switches playlists.
Send a command (next/stop):
{
"channel": "my_channel",
"command": "next"
}Or switch to a playlist by name:
{
"channel": "my_channel",
"playlist": "tavern_ambience"
}Streams MP3 audio for that channel.
Requires login and email in ADMIN_EMAILS whitelist. Shows admin panel with reload controls.
Requires login and email in ADMIN_EMAILS whitelist. Reloads tracks and playlists from their configured sources.
Response:
{
"status": "ok",
"message": "Tracks and playlists reloaded"
}Tracks and playlists can be reloaded without restarting the server:
Navigate to /admin (requires whitelisted email) and click the reload button.
python reload_tracks_cli.pyThis sends SIGHUP to the running server process.
kill -HUP $(pgrep -f "python.*radio.py")- Audio files are streamed from CloudFront via signed URLs (3-day expiry)
- Signed URLs are cached in Redis to avoid regeneration on every play (refreshed 1 hour before expiry)
- If Redis is unavailable, the server falls back to generating fresh URLs
- FFmpeg reads directly from the signed URL and transcodes to MP3
- The server streams
.mp3,.wav,.ogg,.flacfiles (any format FFmpeg supports) - You must have
ffmpeginstalled and accessible from the command line - Background streamer threads terminate if no listeners connect for
IDLE_TIMEOUTseconds (default 900)
Run a black-box listener continuity check against an active channel:
python3 scripts/stream_smoke_test.py \
--base-url https://radio.farreachco.com \
--channel dnd_radio_danya_eng \
--duration 960 \
--listeners 2The default 960 second duration intentionally crosses the 900 second idle timeout.
The test opens real /stream listeners, polls /nowplaying, and fails if a
listener stream closes early or has a read gap longer than --max-gap-seconds.
To start a fresh test channel first, provide a playlist and an authenticated session cookie:
RADIO_SESSION_COOKIE='frc_session=...' python3 scripts/stream_smoke_test.py \
--base-url https://radio.farreachco.com \
--playlist tavern_ambienceFor local dev with DEV_MODE=true, the cookie is not required.
./scripts/service.sh status
./scripts/service.sh logs
./scripts/service.sh restart
./scripts/service.sh stop