This project provides a simple IPTV proxy that aggregates multiple sources (M3U playlists and HDHomeRun tuners) and merges them into a unified M3U and XMLTV feed. This is ideal for use with media frontends like Plex, Jellyfin, or Emby.
- 🧩 Merge multiple M3U sources into a single playlist
- 🗓️ Merge multiple EPG sources (including local files) into a unified
xmltv.xml - 📺 Channel mapping to control display names, guide numbers, logos, and groups
- 🧠 Fallback guide info via guide number when
tvg_idis missing - 🔁 HTTP server that hosts
/lineup.m3uand/xmltv.xml - 🛡️ Robust error handling for malformed sources and network failures
- 🔄 Graceful handling of invalid M3U entries and XML data
- 🌐 Full reverse proxy support with
X-Forwarded-*headers - 💚 Health check endpoints for monitoring and orchestration (liveness, readiness)
- 🎯 NEW: Smart channel mapping with fuzzy matching suggestions
- 🔍 NEW: Automatic duplicate channel detection
- ✅ NEW: EPG validation with coverage analysis
- 🔧 NEW: Dynamic channel management API (reorder, rename, group)
- ⚡ NEW: Advanced caching system with configurable TTL for EPG and M3U data
- 👁️ NEW: Live preview API to test configuration changes before saving
- 💾 NEW: Config backup & restore API to snapshot and recover configuration files
- 📜 NEW: Stream usage history tracking with session duration
- 🔔 NEW: Webhook notifications on channel/EPG refresh events
- 🚦 NEW: Rate limiting on public playlist and guide endpoints
This project was inspired by xTeVe and Threadfin, but I wanted something a little lighter and had better control over using the feeds through reverse proxies.
git clone https://github.com/cbulock/iptv-proxy
cd iptv-proxy
npm installnpm startBy default, the server runs on http://localhost:34400 and serves:
http://localhost:34400/lineup.m3uhttp://localhost:34400/xmltv.xml
To use a custom port, set the PORT environment variable:
PORT=8080 npm startAll configuration is done in providers.yaml, channel-map.yaml, and app.yaml.
Configure application-level settings including authentication, base URL, and caching.
# Admin Authentication (optional)
# Enable to protect the admin UI and API endpoints
# Password MUST be a bcrypt hash - generate with: node scripts/hash-password.js your-password
admin_auth:
username: "admin"
password: "$2a$10$XxXxXxXxXxXxXxXxXxXxXuXxXxXxXxXxXxXxXxXxXxXxXxXxXx" # bcrypt hash
# Session Secret (auto-generated and saved on first run if absent)
# Override to rotate the secret or migrate between instances.
# session_secret: "<auto-generated on first run>"
# Base URL (optional)
# Set when running behind a reverse proxy
# base_url: "https://iptv.example.com"
# Cache Configuration (optional)
# cache:
# epg_ttl: 21600 # EPG cache TTL in seconds (default: 6 hours)
# m3u_ttl: 3600 # M3U cache TTL in seconds (default: 1 hour)Authentication:
- When
admin_authis configured, the admin UI and all management API endpoints require session-based authentication - Access the admin UI at
/admin— you will be redirected to the login page if not authenticated - Protects endpoints:
/,/admin,/api/config/*,/api/reload/*,/api/scheduler/*,/api/mapping/*,/api/channel-health/*,/api/usage/*,/api/channels/*,/api/cache/* - Media endpoints (M3U playlist, XMLTV guide, streams) remain accessible without authentication
- Important: Passwords must be bcrypt hashed for security
Session Secret:
- The server automatically generates and saves a persistent session secret to
app.yamlon first run:session_secret: "<auto-generated 64-char hex value>"
- You can also set it manually (useful when rotating or migrating):
Then add to
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"app.yaml:session_secret: "your-64-char-hex-value-here"
- Minimum length is 32 characters. Shorter values are ignored and a new secret is auto-generated
- If writing to
app.yamlfails (e.g., read-only filesystem), a warning is logged and a per-process random secret is used — existing session cookies will be invalidated on each restart because the signing key changes - Note: even with a persistent secret, session data (login state) is held in memory and lost on restart because the default MemoryStore is used. This is acceptable for single-instance deployments.
Password Hashing:
- Generate a bcrypt hash using the included utility script:
node scripts/hash-password.js your-password
- Copy the generated hash into your
app.yaml:admin_auth: username: "admin" password: "$2a$10$XxXxXxXxXxXxXxXxXxXxXuXxXxXxXxXxXxXxXxXxXxXxXxXxXx"
- Plaintext passwords are not supported - they will be rejected with an error message
Security Best Practices:
- Treat
app.yamlas sensitive and never commit credentials to version control - Restrict file permissions on
app.yaml(e.g.,chmod 600 config/app.yaml) so only the service user can read it - For production deployments, consider loading credentials from environment variables or a secrets manager
- Always use HTTPS when accessing the admin UI remotely to protect credentials in transit
- Add
config/app.yamlto your.gitignoreif it contains real credentials
Define all channel sources here. Each provider combines an M3U or HDHomeRun channel source with an optional EPG (XMLTV) source.
providers:
- name: "ErsatzTV"
url: "https://ersatztv.local/iptv/channels.m3u"
type: "m3u"
epg: "https://ersatztv.local/iptv/xmltv.xml"
- name: "HDHomeRun"
url: "http://antenna.local"
type: "hdhomerun"
- name: "Premium IPTV"
url: "https://example.com/playlist.m3u"
type: "m3u"
epg: "https://example.com/epg.xml"Provider fields:
name- Display name for the provider (used as group-title in the output)url- URL of the M3U playlist or HDHomeRun device base URLtype- Provider type:m3u(default) orhdhomerunepg- (Optional) XMLTV URL providing EPG data for this provider's channels
Use this file to normalize channel metadata. You can define mapping by either channel name or tvg_id.
"The Simpsons":
number: "104"
tvg_id: "C3.147.ersatztv.org"
group: "Entertainment"
"Evening Comedy":
number: "120"
tvg_id: "C20.194.ersatztv.org"
name: "Comedy Channel"
"FOX 47":
number: "47"
tvg_id: "47.1"
logo: "http://example.com/logo.png"Available mapping fields:
name- Override the display namenumber- Set the guide/channel numbertvg_id- Set or override the tvg-idlogo- Set or override the logo URLgroup- Set the group-title (category) for the channelurl- Override the stream URL
nameis tried first when applying the mapping.- If no match is found,
tvg_idis tried. - If neither is matched, the original data is used.
- If no
tvg_idis present after mapping, theguideNumberis used as a fallback.
You can build and run IPTV-Proxy in a container. The folder /config contains your YAML configs and /data contains generated files (like channels.json), so you must mount both directories from your host.
docker build -t iptv-proxy .docker run -d \
--name iptv-proxy \
-p 34400:34400 \
-v /absolute/path/to/your/project/config:/config \
-v /absolute/path/to/your/project/data:/data \
iptv-proxyPermissions are handled automatically — the container will ensure the mounted directories are writable on startup.
Optional: Run with a specific user ID (recommended for multi-user systems):
# Get your UID
id -u # e.g., 1000
docker run -d \
--name iptv-proxy \
-p 34400:34400 \
-e USER_ID=1000 \
-e GROUP_ID=1000 \
-v /absolute/path/to/your/project/config:/config \
-v /absolute/path/to/your/project/data:/data \
iptv-proxyThis will run the container as your user, so files are owned by you instead of root.
If a provider entry uses type: hdhomerun, the server will automatically:
- Fetch
discover.json - Build a fake M3U playlist
- Tag channels with device info
This allows you to use OTA tuners like any other playlist source.
IPTV Proxy is designed to work seamlessly behind reverse proxies like nginx, Caddy, or Traefik. The application automatically detects the correct base URL from forwarded headers.
server {
listen 80;
server_name iptv.example.com;
location / {
proxy_pass http://localhost:34400;
proxy_http_version 1.1;
# Forward client information
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket support (for admin UI)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}For HTTPS with Let's Encrypt:
server {
listen 443 ssl http2;
server_name iptv.example.com;
ssl_certificate /etc/letsencrypt/live/iptv.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/iptv.example.com/privkey.pem;
location / {
proxy_pass http://localhost:34400;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Caddy automatically handles headers and SSL certificates:
iptv.example.com {
reverse_proxy localhost:34400
}With a subfolder path:
example.com {
reverse_proxy /iptv/* localhost:34400
}services:
iptv-proxy:
image: ghcr.io/cbulock/iptv-proxy:latest
container_name: iptv-proxy
volumes:
- ./config:/config
labels:
- "traefik.enable=true"
- "traefik.http.routers.iptv.rule=Host(`iptv.example.com`)"
- "traefik.http.routers.iptv.entrypoints=websecure"
- "traefik.http.routers.iptv.tls.certresolver=letsencrypt"
- "traefik.http.services.iptv.loadbalancer.server.port=34400"Here's a complete Docker Compose configuration:
version: '3.8'
services:
iptv-proxy:
image: ghcr.io/cbulock/iptv-proxy:latest
container_name: iptv-proxy
restart: unless-stopped
ports:
- "34400:34400"
volumes:
- ./config:/config
- ./data:/data
environment:
- TZ=America/New_York
# Optional: Set explicit base URL if auto-detection doesn't work
# - BASE_URL=https://iptv.example.com
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:34400/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s- Go to Settings → Live TV & DVR
- Click "Set Up Plex DVR"
- Enter the tuner URL:
http://your-server:34400/ - Plex will auto-detect the HDHomeRun-compatible lineup
- Enter the EPG URL:
http://your-server:34400/xmltv.xml - Complete the channel mapping in Plex
- Go to Dashboard → Live TV
- Add a new "Tuner Device"
- Select "M3U Tuner"
- Enter the M3U URL:
http://your-server:34400/lineup.m3u - Enter the EPG URL:
http://your-server:34400/xmltv.xml - Save and refresh guide data
- Go to Settings → Live TV
- Click "Add" under TV Sources
- Select "M3U Playlist"
- Enter the M3U URL:
http://your-server:34400/lineup.m3u - Enter the EPG URL:
http://your-server:34400/xmltv.xml - Configure refresh intervals and save
IPTV Proxy includes an advanced caching system to improve performance and reduce load on upstream sources.
Add cache settings to your app.yaml:
cache:
# EPG cache TTL in seconds (default: 21600 = 6 hours)
epg_ttl: 21600
# M3U cache TTL in seconds (default: 3600 = 1 hour)
m3u_ttl: 3600Setting TTL to 0 disables automatic expiration (cache persists until manually cleared).
GET /api/cache/stats- View cache statistics and hit ratesPOST /api/cache/clear- Clear all cachesPOST /api/cache/clear/:name- Clear specific cache (e.g.,epg,m3u)PUT /api/cache/ttl/:name- Update TTL for specific cache
Example: View cache statistics
curl http://localhost:34400/api/cache/statsExample: Clear EPG cache
curl -X POST http://localhost:34400/api/cache/clear/epgExample: Update M3U cache TTL to 2 hours
curl -X PUT http://localhost:34400/api/cache/ttl/m3u \
-H "Content-Type: application/json" \
-d '{"ttl": 7200}'Test configuration changes before saving them with the preview API.
curl -X POST http://localhost:34400/api/preview/m3u \
-H "Content-Type: application/json" \
-d '{
"m3uConfig": {
"urls": [
{
"name": "Test Source",
"url": "https://example.com/playlist.m3u"
}
]
},
"channelMapConfig": {
"Channel Name": {
"number": "100",
"tvg_id": "custom-id"
}
}
}'Returns the merged M3U playlist with your temporary configuration applied.
curl -X POST http://localhost:34400/api/preview/channels \
-H "Content-Type: application/json" \
-d '{
"m3uConfig": { ... },
"channelMapConfig": { ... }
}'Returns channel data as JSON for inspection before saving.
curl -X POST http://localhost:34400/api/preview/epg \
-H "Content-Type: application/json" \
-d '{
"epgConfig": {
"urls": [
{
"name": "Test EPG",
"url": "https://example.com/xmltv.xml"
}
]
},
"channels": [...]
}'Returns the merged XMLTV with your temporary configuration applied.
Create timestamped snapshots of all YAML configuration files and restore them if needed. All endpoints require authentication.
Create a backup:
curl -X POST http://localhost:34400/api/config/backup \
-H "Cookie: <session-cookie>"
# Response: { "status": "created", "name": "backup-2026-01-01T12-00-00", "files": [...] }List backups:
curl http://localhost:34400/api/config/backups \
-H "Cookie: <session-cookie>"
# Response: { "backups": [{ "name": "backup-2026-01-01T12-00-00" }], "count": 1 }Restore a backup:
curl -X POST http://localhost:34400/api/config/backups/backup-2026-01-01T12-00-00/restore \
-H "Cookie: <session-cookie>"
# Response: { "status": "restored", "name": "...", "files": [...] }Delete a backup:
curl -X DELETE http://localhost:34400/api/config/backups/backup-2026-01-01T12-00-00 \
-H "Cookie: <session-cookie>"
# Response: { "status": "deleted", "name": "..." }Backups are stored under data/backups/ and protected against path traversal attacks.
In addition to the active stream view, a history of recently completed stream sessions is available.
View recently completed sessions:
curl http://localhost:34400/api/usage/history \
-H "Cookie: <session-cookie>"Example response:
{
"history": [
{
"ip": "192.168.1.10",
"channelId": "23.1",
"name": "PBS",
"startedAt": "2026-01-01T12:00:00.000Z",
"endedAt": "2026-01-01T12:45:00.000Z",
"durationSeconds": 2700
}
],
"count": 1
}Sessions are returned in reverse-chronological order. The last 100 completed sessions are kept in memory.
Configure outbound HTTP webhooks to be notified when channels or the EPG are refreshed. This enables integration with external automation (e.g. Home Assistant, n8n, custom scripts).
Add a webhooks array to your app.yaml:
webhooks:
- url: https://example.com/hook
events: # optional – omit to receive all events
- channels.refreshed
- epg.refreshed
timeout_ms: 5000 # optional, default 5000Each webhook receives a POST request with the following JSON body:
{
"event": "channels.refreshed",
"timestamp": "2026-01-01T12:00:00.000Z",
"data": {}
}Available events: channels.refreshed, epg.refreshed. Delivery failures are logged but never interrupt the refresh operation.
Public playlist and guide endpoints have per-IP rate limiting to prevent abuse from misconfigured clients or scrapers. Requests from localhost (127.0.0.1 / ::1) are always exempt.
| Endpoint | Limit |
|---|---|
GET /lineup.json |
60 req/min |
GET /lineup.m3u |
60 req/min |
GET /xmltv.xml |
30 req/min |
When the limit is exceeded the server responds with 429 Too Many Requests.
PORT- HTTP server port (default: 34400)CONFIG_PATH- Configuration directory (default:./config)NODE_ENV- Node environment (default:production)
For more detailed configuration examples covering edge cases, see the config/examples/ directory:
providers.example.yaml- Comprehensive provider source examples (M3U, HDHomeRun, EPG)channel-map.example.yaml- Advanced channel mapping scenariosapp.example.yaml- Application settings and scheduler configuration
Problem: M3U playlist is empty or channels are missing.
Solutions:
- Check that your M3U sources are accessible:
curl -I http://your-source/playlist.m3u
- Review server logs for source fetch errors
- Verify config files are valid YAML (use a YAML validator)
- Ensure source URLs in
providers.yamlare correct - Check API status endpoint:
http://localhost:34400/status
Problem: Program guide is empty in your media player.
Solutions:
- Verify channel IDs match between M3U and XMLTV:
- M3U channels need
tvg-idattribute - XMLTV must have
<channel id="...">matching the tvg-id
- M3U channels need
- Check EPG sources are accessible and contain data
- Use channel-map.yaml to normalize tvg_id across sources
- Force EPG refresh:
POST http://localhost:34400/api/reload/epg - Inspect the merged XMLTV:
curl http://localhost:34400/xmltv.xml | head -100
Problem: HDHomeRun tuner doesn't show up in channel list.
Solutions:
- Verify the device is on your network:
ping hdhomerun-device.local - Test the discover endpoint:
curl http://device-ip/discover.json - Ensure
type: "hdhomerun"is set inproviders.yaml - Check firewall rules aren't blocking access
- Try using IP address instead of hostname
Problem: Generated M3U contains wrong server addresses.
Solutions:
- Set explicit
base_urlinapp.yaml:base_url: "https://iptv.example.com"
- Ensure reverse proxy forwards headers correctly:
- X-Forwarded-Proto
- X-Forwarded-Host
- X-Forwarded-For
- Check that your reverse proxy configuration matches the examples above
- Test URL generation:
curl -v http://localhost:34400/lineup.m3u
Problem: Updated config files but changes aren't visible.
Solutions:
- Reload channels:
POST http://localhost:34400/api/reload/channels - Reload EPG:
POST http://localhost:34400/api/reload/epg - Or restart the server:
docker restart iptv-proxy - Verify YAML syntax is valid (indentation matters!)
- Check server logs for validation errors
Problem: Source requires authentication and returns 401/403 errors.
Solutions:
- URL-encode credentials in the source URL:
url: "https://username:password@provider.com/playlist.m3u"
- For complex authentication, consider using a local proxy
- Check if the service requires API keys or tokens (may need code modification)
- Test authentication separately with curl:
curl -u username:password http://provider.com/playlist.m3u
Problem: Server consumes too much RAM.
Solutions:
- Large EPG files can use significant memory - consider:
- Filtering to only needed channels
- Using smaller, source-specific EPG files
- Increasing server resources
- Check for memory leaks by monitoring over time
- Reduce the number of concurrent source fetches
- Consider pagination or streaming for very large files
Problem: Admin interface shows "Not Available" message.
Solutions:
- Build the admin UI:
npm run admin:build
- Or run in development mode with hot reload:
npm run dev
- For Docker, ensure you're using an image with the admin UI built
- Check that
public/admin/index.htmlexists
Problem: EPG doesn't auto-refresh or scheduled tasks don't execute.
Solutions:
- Check cron expression syntax in
app.yaml - Verify scheduler is running: check
/api/scheduler/jobsendpoint - Review server logs for scheduler errors
- Test cron expressions using an online validator
- Ensure time zone is set correctly (TZ environment variable)
Enable verbose logging for troubleshooting:
DEBUG=* npm startOr in Docker:
environment:
- DEBUG=*If you're still experiencing issues:
- Check the API documentation for endpoint details
- Review server logs for error messages
- Use the
/statusendpoint to check system health - Open an issue on GitHub with:
- Server logs
- Configuration files (remove sensitive data)
- Steps to reproduce the problem
- Expected vs actual behavior
The server provides several API endpoints for configuration and management. See API.md for complete documentation.
Key Endpoints:
GET /lineup.m3u- M3U playlistGET /xmltv.xml- EPG dataGET /status- System diagnosticsGET /health- Health checkPOST /api/reload/channels- Reload M3U sourcesPOST /api/reload/epg- Reload EPG dataGET /api/config/*- Get/update configuration
- Your XMLTV sources can be remote URLs or local files (use
file://prefix). - M3U sources also support
file://URLs for local files. - All
tvg_ids in channels must match the<channel id="...">in EPG sources to link correctly. - Duplicate
tvg_ids will be overwritten in favor of the last one processed. - Configuration files are automatically created with defaults on first run.
- The server automatically caches channels and EPG data for performance.
This project includes comprehensive test coverage and code quality tools:
- Unit Tests: Test individual functions in isolation
- Integration Tests: Test complete workflows with mocked dependencies
- Output Format Validation: Ensure M3U and XMLTV outputs are valid
- Linting: ESLint for JavaScript/ESM and Vue.js code
- Formatting: Prettier for consistent code style
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run linter
npm run lint
# Auto-fix lint issues
npm run lint:fix
# Check code formatting
npm run format:check
# Auto-format code
npm run formatFor detailed information about testing, see TESTING.md.
MIT