A privacy-focused radio station directory designed for Tor and I2P networks. This FastAPI application provides both a JSON API and a server-rendered HTML interface for discovering, submitting, and managing radio stations accessible through anonymous networks.
This is the standard/production version - cover art URLs are submitted for manual admin review with a blur toggle. Images are embedded directly from their external sources and are never downloaded or hosted locally. This is the recommended version for most deployments.
Note: Alternative versions are available - see Versions section for automatic NSFW detection or auto-accept options.
- Radio Station Directory - Browse and search stations by network (Tor/I2P), genre, and online status
- Station Submission - Submit new stations with automatic stream validation and approval
- Stream Validation - Validates that URLs point to actual audio streams (not HTML, images, etc.)
- Health Monitoring - Periodic checks to track station online/offline status
- Admin Dashboard - Web-based admin panel for station moderation
- Admin CLI - Command-line tools for bulk operations (import, export, approve, reject)
- Network-Aware Routing - Automatic proxy routing through Tor SOCKS5 or I2P HTTP
- Rate Limiting - API protection with slowapi (60/min for listings, 5/min for submissions)
- No JavaScript - (Optional minimal JS in the Admin Panel, can be stripped in the backend)
- FastAPI - Modern async web framework
- Uvicorn - ASGI server
- Pydantic - Data validation
- SQLite - Embedded database
- Jinja2 - HTML templating
- aiohttp - Async HTTP client with SOCKS proxy support
- slowapi - Rate limiting
- ntfy.sh - Push notifications for admin alerts (cover art review)
- Python 3.8+
- Tor service running (for Tor station validation)
- I2P router with HTTP proxy (for I2P station validation)
# Clone the repository
git clone <repository-url>
cd Radio-Registry-API
# Run the setup script
./setup.sh
# Or manually:
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python -c "import database; database.init_db()"Edit config.py to customize settings:
# Server settings
HOST = "127.0.0.1"
PORT = 8080
# Proxy settings (adjust to your Tor/I2P configuration)
TOR_SOCKS_PROXY = "socks5://127.0.0.1:9050"
I2P_HTTP_PROXY = "http://127.0.0.1:4444"
# IMPORTANT: Change these in production!
ADMIN_PASSWORD = "changeme"
ADMIN_SECRET_KEY = "super-secret-key-change-me"source venv/bin/activate
uvicorn main:app --host 127.0.0.1 --port 8080| Method | Endpoint | Description | Rate Limit |
|---|---|---|---|
| GET | /api/stations |
List approved stations (paginated) | 60/min |
| GET | /api/stations/{id} |
Get station details | - |
| GET | /api/stations/{id}/cover |
Get station | - |
| POST | /api/submit |
Submit a new station | 5/min |
| GET | /api/stats |
Get directory statistics | - |
| GET | /api/genres |
List available genres | - |
| GET | /api/health |
API health check | - |
GET /api/stations
| Parameter | Type | Description |
|---|---|---|
network |
string | Filter by network: tor or i2p |
genre |
string | Filter by genre |
online_only |
boolean | Only show online stations |
page |
integer | Page number (default: 1) |
per_page |
integer | Items per page (default: 50, max: 200) |
# List all Tor stations
curl "http://localhost:8080/api/stations?network=tor"
# List online electronic stations
curl "http://localhost:8080/api/stations?genre=Electronic&online_only=true"
# Get directory stats
curl "http://localhost:8080/api/stats"
# Submit a new station
curl -X POST "http://localhost:8080/api/submit" \
-H "Content-Type: application/json" \
-d '{
"name": "My Radio Station",
"stream_url": "http://example.onion:8000/stream",
"network": "tor",
"genre": "Electronic"
}'Station List Response
{
"stations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Example Radio",
"stream_url": "http://example.onion:8000/stream",
"homepage": "http://example.onion",
"network": "tor",
"genre": "Electronic",
"codec": "MP3",
"bitrate": 128,
"language": "English",
"country": "Unknown",
"is_online": true,
"last_check_time": "2024-01-15T12:00:00Z"
}
],
"total": 42,
"page": 1,
"per_page": 50,
"pages": 1
}Stats Response
{
"total_stations": 100,
"online_stations": 75,
"tor_stations": 60,
"i2p_stations": 40,
"pending_submissions": 5
}The server-rendered HTML interface is accessible at:
| Path | Description |
|---|---|
/ |
Station listing with filters |
/station/{id} |
Station detail page |
/submit |
Station submission form |
/about |
About page |
/admin |
Admin login |
/admin/dashboard |
Admin dashboard |
The admin.py script provides command-line management tools:
# List all stations
python admin.py list
# List pending submissions
python admin.py pending
# Show statistics
python admin.py stats
# Approve a station
python admin.py approve <station-id>
# Reject a station
python admin.py reject <station-id> --reason "Invalid stream"
# Delete a station
python admin.py delete <station-id>
# Get station info
python admin.py info <station-id>
# Import stations from JSON
python admin.py import stations.json --network tor --approve
# Export stations to JSON
python admin.py export output.json --network tor --status approved[
{
"name": "Station Name",
"stream_url": "http://example.onion:8000/stream",
"homepage": "http://example.onion",
"genre": "Electronic",
"codec": "MP3",
"bitrate": 128,
"language": "English",
"country": "Unknown"
}
]The checker.py script performs smart health checks with escalating rechecks to prevent false offline status from transient failures.
Stations have three health states:
| Status | Description |
|---|---|
| online | Last check succeeded |
| offline | Failed recently, but was online within 12 hours (being rechecked) |
| dead | No successful response for 12+ hours |
When a station fails a check, it doesn't immediately show as offline. Instead, the system performs escalating rechecks:
✓ Online → next check in 4 hours
✗ Fail #1 → recheck in 5 minutes
✗ Fail #2 → recheck in 15 minutes
✗ Fail #3 → recheck in 1 hour
✗ Fail #4+ → confirmed offline, regular 4-hour checks resume
If a station remains unreachable for 12+ hours, it's marked as dead.
This prevents reliable stations from appearing offline due to temporary network issues on Tor/I2P.
All major settings can be customized in config.py. Here's a complete reference:
| Setting | Default | Description |
|---|---|---|
HEALTH_CHECK_TIMEOUT |
30 | Seconds to wait for a station to respond before marking as failed |
HEALTH_CHECK_INTERVAL_HOURS |
4 | Hours between regular health checks for online stations |
RECHECK_INTERVALS_MINUTES |
[5, 15, 60] | Escalating recheck intervals (in minutes) for failed stations |
DEAD_THRESHOLD_HOURS |
12 | Hours without response before a station is marked "dead" |
Example: Faster health checks (hourly)
HEALTH_CHECK_INTERVAL_HOURS = 1 # Check online stations every hour instead of 4
HEALTH_CHECK_TIMEOUT = 20 # Shorter timeout for faster checksExample: More aggressive failure detection
RECHECK_INTERVALS_MINUTES = [2, 5, 15] # Faster escalation: 2min, 5min, 15min
DEAD_THRESHOLD_HOURS = 6 # Mark dead after 6 hours instead of 12Example: More lenient (for unreliable networks)
RECHECK_INTERVALS_MINUTES = [10, 30, 120] # Slower escalation: 10min, 30min, 2hr
DEAD_THRESHOLD_HOURS = 24 # Wait 24 hours before marking dead| Setting | Default | Description |
|---|---|---|
HOST |
"127.0.0.1" | Address to bind the server to |
PORT |
8080 | Port to run the server on |
TOR_SOCKS_PROXY |
"socks5://127.0.0.1:9050" | Tor SOCKS5 proxy for validating .onion streams |
I2P_HTTP_PROXY |
"http://127.0.0.1:4444" | I2P HTTP proxy for validating .i2p streams |
| Setting | Default | Description |
|---|---|---|
DEFAULT_PAGE_SIZE |
50 | Default number of stations per page |
MAX_PAGE_SIZE |
200 | Maximum stations per page (prevents abuse) |
CORS_ORIGINS |
localhost | Allowed CORS origins for API access |
| Setting | Default | Description |
|---|---|---|
MAX_NAME_LENGTH |
100 | Maximum characters for station names |
MAX_URL_LENGTH |
500 | Maximum characters for URLs |
DEFAULT_GENRES |
[list] | Available genre options for submissions |
DEFAULT_LANGUAGES |
[list] | Available language options |
In config.py:
# Recheck intervals for failed stations (in minutes)
RECHECK_INTERVALS_MINUTES = [5, 15, 60] # 5 min, 15 min, 1 hour
# Time threshold for "dead" status (in hours)
DEAD_THRESHOLD_HOURS = 12
# Regular check interval for online stations
HEALTH_CHECK_INTERVAL_HOURS = 4# Run manually
python checker.py
# Set up cron job (every 5 minutes - uses smart scheduling)
*/5 * * * * /path/to/venv/bin/python /path/to/checker.py >> /path/to/checker.log 2>&1Note: The cron runs every 5 minutes, but the checker only checks stations that are actually due. Online stations won't be hammered - they're only checked every 4 hours. The frequent cron is to catch the escalating rechecks for recently-failed stations.
============================================================
Health check run - 2024-01-15 12:00:00
============================================================
Stations due for check: 5
Tor: 3, I2P: 2
Regular: 2, Rechecks: 2, Dead: 1
Checking 3 Tor stations via socks5://127.0.0.1:9050...
[regular] http://example.onion/stream
✓ online
[recheck #2] http://failing.onion/stream
✗ offline
[dead-check] http://dead.onion/stream
✗ offline
============================================================
Health check complete
Checked: 5
Online: 2, Offline: 3
Recovered: 1 (were offline, now online)
============================================================
Before deploying to production, complete these steps:
- Change admin password - Update
ADMIN_PASSWORDinconfig.pyto a strong, unique password - Generate secret key - Replace
ADMIN_SECRET_KEYwith a secure random value:python -c "import secrets; print(secrets.token_hex(32))" - Review CORS origins - Update
CORS_ORIGINSto only allow your frontend domains
- Set Tor address - Update
TOR_BASE_URLandMIRRORS["tor"]with your .onion address - Set I2P address (if using) - Update
MIRRORS["i2p"]with your .b32.i2p address - Set clearnet URL (if using) - Uncomment and configure
MIRRORS["clearnet"]
- Verify Tor proxy - Ensure Tor is running and
TOR_SOCKS_PROXYpoints to correct address - Verify I2P proxy (if using) - Ensure I2P router is running and
I2P_HTTP_PROXYis correct - Configure firewall - Block direct access to port 8080 from public networks
- Test stream validation - Verify the API can reach Tor/I2P streams through configured proxies
- Set up health checker - Configure cron job for
checker.py - Enable systemd service - Install and enable the service file
A systemd service file is provided (radio-api.service):
# Copy and edit the service file
sudo cp radio-api.service /etc/systemd/system/
sudo nano /etc/systemd/system/radio-api.service
# Enable and start
sudo systemctl enable radio-api
sudo systemctl start radio-api
# Check status
sudo systemctl status radio-apiThe service file includes security features:
- Memory limit (200MB)
- CPU quota (50%)
- No new privileges
- Read-only home directory
- Private /tmp
For HTTPS access, use a reverse proxy like nginx or Cloudflare Tunnel:
server {
listen 443 ssl;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}.
├── main.py # FastAPI application and routes
├── database.py # SQLite database operations
├── models.py # Pydantic models
├── config.py # Configuration settings
├── stream_validator.py # Audio stream validation
├── checker.py # Health check script
├── admin.py # CLI admin tool
├── requirements.txt # Python dependencies
├── setup.sh # Setup script
├── radio-api.service # Systemd service file
├── stations.db # SQLite database
├── templates/ # Jinja2 HTML templates
│ ├── base.html
│ ├── index.html
│ ├── station.html
│ ├── submit.html
│ ├── about.html
│ ├── admin_login.html
│ ├── admin.html
│ ├── 404.html
│ └── 500.html
└── static/ # Static files
Ensure Tor is running with SOCKS proxy on port 9050:
# Install Tor
sudo apt install tor
# Verify it's running
curl --socks5 127.0.0.1:9050 https://check.torproject.org/api/ipEnsure I2P HTTP proxy is available on port 4444:
# Download I2P installer
wget https://geti2p.net/en/download/2.10.0/clearnet/https/files.i2p-projekt.de/i2pinstall_2.10.0.jar/download -O i2pinstall.jar
# Install I2P (follow the GUI installer prompts)
java -jar i2pinstall.jar
# Start I2P router
~/i2p/i2prouter start
# Verify proxy (should return I2P content)
curl --proxy http://127.0.0.1:4444 http://i2p-projekt.i2pAfter installation, you must configure config.py:
- Change default credentials (CRITICAL for security):
ADMIN_PASSWORD = "your-secure-password-here"
ADMIN_SECRET_KEY = "your-random-secret-key-here" # Use secrets.token_hex(32)- Set your service URLs:
MIRRORS = {
"tor": {
"name": "Tor",
"url": "http://your-onion-address.onion",
"host": "your-onion-address.onion",
},
"i2p": {
"name": "I2P",
"url": "http://your-i2p-address.b32.i2p",
"host": "your-i2p-address.b32.i2p",
},
# Clearnet mirror (optional):
# "clearnet": {
# "name": "Clearnet",
# "url": "https://your-domain.com",
# "host": "your-domain.com",
# },
}Setting Up Tor Hidden Service (optional, must have for Tor station checking
- Edit
/etc/tor/torrc:
HiddenServiceDir /var/lib/tor/radio-registry/
HiddenServicePort 80 127.0.0.1:8080
- Restart Tor:
sudo systemctl restart tor- Get your .onion address:
sudo cat /var/lib/tor/radio-registry/hostname- Install I2P and start the router
- Configure an I2P server tunnel pointing to
127.0.0.1:8080 - Get your
.b32.i2paddress from the I2P router console - Update
MIRRORSinconfig.pywith your .i2p address
The API supports push notifications via ntfy.sh to alert admins when needs review.
Setup:
- Choose a unique, private topic name (e.g.,
my-radio-covers-abc123) - Configure in
config.py:
NTFY_TOPIC = "my-radio-covers-abc123"- Subscribe to your topic:
- Web: Visit
https://ntfy.sh/my-radio-covers-abc123 - Mobile: Install the ntfy app and subscribe to your topic
- Desktop: Use the PWA or CLI tool
- Web: Visit
Notifications are sent automatically when new is submitted and needs approval.
- MP3
- AAC
- OGG Vorbis
- Opus
- FLAC
- WAV
- WMA
- Direct streams (Icecast/Shoutcast with ICY metadata)
- HLS (HTTP Live Streaming / m3u8 playlists)
- DASH (Dynamic Adaptive Streaming)
Cover art URLs submitted with stations are queued for manual admin review. Images are embedded directly from their external source with a CSS blur overlay - the admin can toggle the blur to review for NSFW content. No images are ever downloaded or hosted locally - approved cover art simply makes the external URL visible to users.
This approach:
- Eliminates legal liability from hosting third-party images
- Reduces storage and bandwidth requirements
- Keeps the system simple and lightweight
- Still allows NSFW filtering through manual review
| Endpoint | Limit |
|---|---|
/api/stations |
60 requests/minute |
/api/submit |
5 requests/minute |
| Other endpoints | No limit |
This project has three versions available to suit different needs:
- Cover Art: External URL embedding only (no downloading)
- Review: Manual admin review with blur toggle
- Dependencies: Lightweight, no ML libraries required
- Best for: Production deployments prioritizing simplicity and legal safety
- Cover Art: Downloads and mirrors locally
- Review: Automatic NSFW detection via AI model
- Dependencies: Requires PyTorch and NSFW detection model
- Best for: You don't care about legal compliance and you want something autonomous
- Cover Art: External URL embedding only (no downloading)
- Review: None - covers are automatically accepted
- Dependencies: Lightweight, no ML libraries required
- Best for: Balance between both, autonomous, probably safe legally, potential problems for app's relying on your api that barr nsfw content
Choose the version that best fits your use case, resources, and moderation requirements.
- Never expose port 8080 to the public internet - only access via Tor/I2P
- Change default passwords immediately after installation
- Enable 2FA for admin panel access
- Run as unprivileged user with systemd service
- Regular updates - keep dependencies updated for security patches
- Backup your database regularly (
stations.db)
This project isn't maintained. If you want to change it, fork and make your own repo. I won't be accepting pull requests or issues.
This project is licensed under the Apache License 2.0. See LICENSE for details.