Turn a Linux PC with an ATSC tuner card into a network TV tuner that Plex, Jellyfin and VLC can use.
LinTv presents itself as an HDHomeRun, the network tuner that media servers already support. It's built for a Hauppauge WinTV-HVR-1800 PCIe card receiving US/Canadian over-the-air (ATSC 1.0) broadcasts. It should work with any ATSC card that has a Linux DVB driver.
- Channel scan: finds every station your antenna can receive, including subchannels (8.1, 8.2, β¦). If a channel comes in on more than one frequency, it picks the strongest signal.
- Live TV: streams each channel over HTTP as a standard MPEG-TS, containing just that channel.
- Program guide: reads the guide data stations broadcast over the air and serves it as XMLTV, with no internet guide service needed.
- HDHomeRun emulation: serves
discover.json,lineup.jsonandlineup_status.json, so Plex and Jellyfin can add it as a tuner. - Channel map: attach network names (e.g.
FOX) to local call signs, so media servers show and match channels by network. - Scheduled guide refresh: rescans the guide at the times of day you choose.
- Diagnostics: keeps daily log files you can read over HTTP.
For how it works inside (tuner sharing, PSIP parsing, demuxing), see docs/ABOUT.md.
- A Linux box (Debian/Ubuntu below) with an ATSC tuner card that shows up under
/dev/dvb - An antenna
- The .NET 10 ASP.NET Core runtime
- To build and publish from a dev machine: the .NET 10 SDK and PowerShell (
publish.ps1)
The HVR-1800 uses the in-kernel cx23885 driver, so no extra driver install is needed.
sudo dmesg | grep -i cx23885 # driver loaded, card detected
ls /dev/dvb/adapter0 # expect: demux0 dvr0 frontend0 net0If /dev/dvb is missing, install the firmware and reboot:
sudo apt install linux-firmwareThe DVB tools are optional but useful for checking signal lock while LinTv holds the tuner:
sudo apt install dvb-tools
dvb-fe-tool -a 0 -m # live frontend status / signal monitorLinTv targets net10.0 and needs the ASP.NET Core runtime. Install the SDK instead if you'll build on the box.
Ubuntu 26.04+: .NET 10 is in the Ubuntu archive:
sudo apt update
sudo apt install aspnetcore-runtime-10.0 # or: dotnet-sdk-10.0Ubuntu 24.04: use the Ubuntu .NET backports PPA:
sudo add-apt-repository ppa:dotnet/backports
sudo apt update
sudo apt install aspnetcore-runtime-10.0 # or: dotnet-sdk-10.0Debian: use the Microsoft package feed (replace 12 with your Debian version):
wget https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -O /tmp/packages-microsoft-prod.deb
sudo dpkg -i /tmp/packages-microsoft-prod.deb
sudo apt update
sudo apt install aspnetcore-runtime-10.0 # or: dotnet-sdk-10.0Verify:
dotnet --list-runtimes # expect Microsoft.AspNetCore.App 10.0.xDon't mix the Ubuntu and Microsoft feeds for the same packages. If you do, dotnet may fail to find installed runtimes. See the .NET on Ubuntu docs if you run into this.
Create a service user and the install and state directories:
sudo useradd --system --no-create-home --groups video lintv
sudo mkdir -p /opt/lintv
sudo chown -R $USER:lintv /opt/lintv
sudo find /opt/lintv -type d -exec chmod 2750 {} + # setgid: new files inherit the lintv group
sudo find /opt/lintv -type f -exec chmod g+r,o-rwx {} +
# State (channels, guide, channel map, logs): writable by both you and the service
sudo install -d -o $USER -g lintv -m 2770 /var/lib/lintvAfter this setup:
- You own
/opt/lintv, sopublish.ps1 -RemoteDir /opt/lintvdeploys withoutsudo. - The
lintvgroup can read it but not write it. The service doesn't need write access to its own binaries. - The setgid bit (the
2in2750) means files you deploy later get thelintvgroup automatically.
The lintv user needs the video group to open /dev/dvb/*. The video group only controls the device files, not /opt/lintv.
To run LinTv as your own user (e.g. dotnet /opt/lintv/LinTv.Api.dll), add yourself to that group too, or it fails with Permission denied on frontend0:
ls -l /dev/dvb/adapter0 # confirm the group is "video"
sudo usermod -aG video $USER
# log out and back in (or `newgrp video` in the current shell), then check:
groups # should list videopublish.ps1 (Windows PowerShell) builds for linux-x64 and uploads the output. It looks for connection settings in this order: script parameters, then environment variables, then a gitignored publish.settings.json (copy it from publish.settings.example.json):
| Setting | Env var | Parameter |
|---|---|---|
| Host | LINTV_HOST |
-HostName |
| User | LINTV_USER |
-User |
| Password | LINTV_PASSWORD |
-Password |
| Port (default 22) | LINTV_PORT |
-Port |
.\publish.ps1 -RemoteDir /opt/lintv # deploy straight to the install dir
.\publish.ps1 # or stage to /tmp/lintvEach run empties the target directory before extracting, so stale files from earlier builds don't linger. That includes appsettings.json, so keep server-specific settings somewhere the deploy won't overwrite them (see Configure).
With a password set, the upload goes through the Posh-SSH module, because Windows OpenSSH can't take a password non-interactively. Install it once with Install-Module Posh-SSH -Scope CurrentUser. With no password set, the script uses plain ssh/scp with SSH key auth.
Settings live in the LinTv section of /opt/lintv/appsettings.json:
"Urls": "http://0.0.0.0:5249",
"LinTv": {
"StorageDirectory": "/var/lib/lintv",
"Adapter": 0,
"LockWaitSeconds": 5,
"ChannelScanTimeoutSeconds": 5,
"EpgScanTimeoutSeconds": 60,
"EpgScanTimes": ["11am", "11pm"],
"StreamStallTimeoutSeconds": 15,
"TunerCount": 1,
"LogRetentionDays": 7,
"FriendlyName": "LinTv",
"DeviceId": "4C696E54"
}| Setting | Meaning |
|---|---|
Urls |
Listen address. 0.0.0.0 makes it reachable on the LAN. The ASP.NET default, localhost:5000, is loopback only. |
StorageDirectory |
Holds the lineup (channels.json), the guide (guide.json), your channel map (channel-map.json), the logs (logs/) and the web UI's Data Protection keys (keys/). |
Adapter |
The N in /dev/dvb/adapterN. |
LockWaitSeconds |
How long to wait for a signal lock before treating an RF channel as empty. |
ChannelScanTimeoutSeconds |
How long a scan waits for a locked channel's channel table. |
EpgScanTimeoutSeconds |
The most time a guide scan spends per frequency. If it runs out, it keeps what it has collected, usually the next several hours. |
EpgScanTimes |
When to rescan the guide automatically, in the server's local time, e.g. ["11am", "11pm"]. Also accepts "11:30pm" or "23:00". [] (the default) turns it off. An invalid entry stops LinTv at startup with an error. |
TunerCount |
Tuners reported to Plex and Jellyfin, which cap how many streams they open at this. Default 1. Several streams can play at once only if they're on the same RF channel (e.g. 8.1 and 8.2). Raise this if most of your viewing is on one RF channel. A stream on a different RF channel than one already playing is still refused. |
StreamStallTimeoutSeconds |
A stream that has sent nothing for this long (signal lost, or a client that never fully connected) is ended and the tuner released. Default 15. |
LogRetentionDays |
Days of log files to keep. |
FriendlyName |
The name Plex and Jellyfin show. |
DeviceId |
8 hex digits identifying the tuner to clients. Keep it stable: changing it makes clients see a new device and lose their channel setup. |
Any setting can also be set with an environment variable, which survives redeploys. For example, LinTv__Adapter=1 in the systemd unit.
/etc/systemd/system/lintv.service:
[Unit]
Description=LinTv HDHomeRun emulator
After=network-online.target
Wants=network-online.target
[Service]
User=lintv
Group=lintv
SupplementaryGroups=video
WorkingDirectory=/opt/lintv
ExecStart=/usr/bin/dotnet /opt/lintv/LinTv.Api.dll
Environment=DOTNET_NOLOGO=1
Restart=on-failure
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now lintv
journalctl -u lintv -f-
Scan for channels. Open the web UI at
http://<host>:5249/and click Start channel scan on the dashboard. A full scan takes a few minutes, and the card shows progress as it runs. -
Scan the guide. On the dashboard, click Start EPG scan. It takes up to
EpgScanTimeoutSecondsper frequency. Stations only broadcast the next half day to a few days of guide data, so setEpgScanTimesto keep it fresh. Twice a day is plenty. -
Watch something. Open
http://<host>:5249/lineup.m3uin VLC (Media β Open Network Stream) to get a channel list. -
Add it to your media server:
- Jellyfin: Dashboard β Live TV β Tuner Devices β Add β HDHomeRun,
http://<host>:5249. Then go to TV Guide Data Providers β Add β XMLTV,http://<host>:5249/guide.xml. - Plex: Settings β Live TV & DVR β Set up β "Don't see your device?",
<host>:5249. When asked for a guide, choose XMLTV,http://<host>:5249/guide.xml.
Clients won't find LinTv on their own yet, so enter the address by hand.
- Jellyfin: Dashboard β Live TV β Tuner Devices β Add β HDHomeRun,
Rerun the channel scan if stations change frequency or number. A stream that returns 503 with "not found β¦ try a channel scan" is the sign. The guide refreshes on the EpgScanTimes schedule. You can also start an EPG scan by hand at any time.
Open http://<host>:5249/ in a browser:
-
Dashboard: start channel and EPG scans and watch their progress. See what's holding the tuner, and Disconnect it if a stream is stuck. It also shows the guide schedule and next run, lineup and guide counts, and the addresses to enter in Jellyfin or Plex.
-
Channels: every scanned channel with its broadcast name, mapped name, RF channel, and the signal at scan time (colour-coded by SNR). Each row links to the signal meter and to the stream.
-
Signal: a live signal meter. Pick a channel and it tunes, samples for a few seconds, and reports:
- lock percentage;
- SNR and strength (min/avg/max) against the scan-time readings;
- a verdict: Good, Marginal, Unstable or No lock.
Tick keep measuring to repeat it while you adjust the antenna. Each run adds a row to a history table.
Stations broadcast their call sign (WTVT-DT), but media servers match channels to guide listings and logos more reliably by network (FOX). A channel map gives a channel extra names, used in two places:
lineup.json(the HDHomeRun tuner in Jellyfin and Plex): the first name replaces the call sign as the channel'sGuideName. This is the name Jellyfin's HDHomeRun tuner shows and matches on.guide.xml: every name is added as an extra<display-name>, after the call-sign names.
So put the name you want clients to show first.
curl -X PUT -H "Content-Type: application/json" -d '["FOX"]' http://<host>:5249/channel-map/13.1Or edit /var/lib/lintv/channel-map.json directly. Changes apply on the next request, with no restart needed:
[
{ "Channel": "13.1", "DisplayNames": [ "FOX" ] },
{ "Channel": "8.1", "DisplayNames": [ "NBC" ] }
]To have an AI assistant draft the map for your area, give it docs/LLM-Channel-Map-Instructions.md along with your /var/lib/lintv/channels.json (the scan result; lineup.json already has the map merged in, so it isn't a good starting point).
| Endpoint | Purpose |
|---|---|
GET /, /Channels, /Signal |
Web UI (see Web UI) |
POST /scan/channels, GET /scan/channels |
Start a channel scan in the background (409 if one is running) / show its progress |
POST /scan/epg, GET /scan/epg |
Start a guide scan / show its progress |
GET /scan/signal/{major}.{minor}/{index?}?seconds=N |
Tune to a channel and sample its signal for N seconds (default 5, max 30): lock %, strength and SNR min/avg/max, time to lock, and the scan-time readings for comparison. Works on channels too weak to lock. Holds the tuner, so it returns 409 while the tuner is busy on another frequency. |
GET /stream/{major}.{minor} |
Live stream of a channel, e.g. /stream/8.1 (the best-signal copy) |
GET /stream/{major}.{minor}/{index} |
A specific copy of a channel received on several frequencies, e.g. /stream/10.1/1 |
GET /stream/status |
What holds the tuner: frequency, priority, holder count, since when |
POST /stream/disconnect |
Force-end everything holding the tuner (stuck streams, scans, measurements) |
GET /lineup.m3u |
Channels as an M3U playlist |
GET /guide.xml |
Program guide as XMLTV |
GET /discover.json, /lineup.json, /lineup_status.json |
HDHomeRun emulation for Plex/Jellyfin |
GET /channel-map, PUT/DELETE /channel-map/{major}.{minor} |
View / set / remove extra guide names |
GET /logs?lines=N&file=F, GET /logs/files |
Read the log (default: newest file, last 200 lines) / list log files |
Logs. Logs go to the console (journald) and to daily files in /var/lib/lintv/logs:
curl "http://<host>:5249/logs?lines=500"
curl "http://<host>:5249/logs?lines=500" | grep -E "WRN|ERR"For tuner and stream detail, set "LinTv": "Trace" under Logging:LogLevel. To send the detail only to the file and keep journald quieter, set it under Logging:File:LogLevel:
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "LinTv": "Information" },
"File": { "LogLevel": { "LinTv": "Trace" } }
}Messages worth knowing:
| Message | Meaning |
|---|---|
No data from dvr0 for Ns -- tuner stalled or signal lost? |
A stream is open but no packets are arriving. Includes the current lock and SNR. |
Waited Ns for tuner gate |
Something held the tuner for a long time, such as a slow tune or a stuck caller. |
No lock after N ms (strength, SNR) |
Tuning failed. Strength and SNR show whether there was any signal at all. |
Tuner released with no holders |
A bug: a release without a matching acquire. |
Stream X for client <outcome> after Ns (MB, Mbps) |
One line per stream when it ends. |
Common problems:
- A station is hard to lock or keeps breaking up: check it with
curl "http://<host>:5249/scan/signal/44.1?seconds=15".lockedPercentbelow 100, or an SNR range that dips below about 15 dB, means marginal reception. Compare againstscanSnrDb, and try the antenna position or an amplifier. If the channel is received on several frequencies, check the alternates (/scan/signal/44.1/1) too. Permission deniedonfrontend0: the user isn't in thevideogroup, or hasn't logged in again since being added.- Listening on
localhost:5000:appsettings.jsonwasn't found or has noUrlssetting. - "Tuner in use": there's one tuner. Any number of streams can share it if they're on the same RF channel (e.g. 8.1 and 8.2), but a stream on one frequency blocks scans and streams on other frequencies until it ends. The dashboard's Tuner card shows what holds it (also
GET /stream/status). If it's stuck, for example on a stream Jellyfin abandoned, click Disconnect or runcurl -X POST http://<host>:5249/stream/disconnect. That ends every stream, scan and measurement holding the tuner. - Missing guide text for a station: it may send compressed text, which isn't supported yet (see ABOUT).
The solution is LinTv.slnx: .NET 10, with package versions in Directory.Packages.props. It builds and runs on Windows, but tuning needs Linux. See CLAUDE.md for conventions and docs/ABOUT.md for the architecture.
MIT, see LICENSE.md.