Technical Deep Dive
The technical companion to README.md: how Freetvarr works inside, and why.
Contents
- Architecture
- The TVHeadend API surface
- Import state machine
- Why the import is a hardlink
- Series recordings as autorec rules
- Remove after import
- Stop a recording
- Ad removal
- Live progress indicators
- Guide days and history
- Live TV
- Mobile layout
- Full environment reference
- Docker deployment
- Security model
- Local development
- Project layout
- Scripts
- Testing
- Screenshots
- Documentation site
Architecture
A single Node process runs everything. The Express server (src/server.js) serves the Vue 3 SPA and the REST API; the scheduler (src/scheduler.js) wires node-cron to the sync engine and reloads whenever the cron setting changes; the sync engine (src/sync.js) lists TVHeadend's finished recordings, matches them to series folders, imports new episodes into the media library, and persists every outcome to SQLite. The guide layer (src/epg.js) caches TVHeadend's EPG and recording state for the TV GUIDE tab. After any sync that imported something, the Plex client (src/plex.js) refreshes the configured library section, and only then is a remove queued back to TVHeadend. The live TV layer (src/live-tv.js) pulls a channel's transport stream from TVHeadend, pipes it through ffmpeg into rolling HLS files under the temp folder, and serves them to the browser player (Live TV).
The main difference from Fetcharr, which this forked from, is that the recorder is on the same filesystem. Fetcharr downloaded each episode from a set-top box over HTTP and could only delete the source through a vendor cloud service. Freetvarr reads a local file and removes it through authenticated calls to TVHeadend's API.
The TVHeadend API surface
Everything goes through TVHeadend's JSON API at <tvh_url>/api/<path>, with HTTP Digest auth (or Basic, whichever challenge TVHeadend sends; src/http-auth.js builds the header), a 15 s timeout, and application/x-www-form-urlencoded bodies on writes. src/tvheadend.js is the only module that speaks it.
| Endpoint | Used for |
|---|---|
serverinfo | TEST CONNECTION; reports the version and API version. Also the unauthenticated probe behind AUTO-DISCOVER TVHEADEND, which tries port 9981 on the address the browser used to reach Freetvarr, then each host LAN address, tvheadend, host.docker.internal, and 127.0.0.1, in parallel with a 1.5 s timeout. A 200 with sw_version or a 401 with the tvheadend realm counts as a hit; loopback hits are dropped when a LAN address answers |
channel/grid | The channel lineup, sorted by number, disabled channels dropped |
epg/events/grid | The guide, paged 2000 events at a time until the window is covered |
epg/events/load | One programme's detail |
dvr/entry/grid_upcoming | Scheduled and in-progress recordings |
dvr/entry/grid_finished | The import queue: everything TVHeadend has finished |
dvr/entry/create_by_event | RECORD |
dvr/entry/cancel, dvr/entry/stop | CANCEL RECORDING for a scheduled entry; STOP RECORDING for a running one (see Stop a recording) |
dvr/entry/remove | Remove after import: deletes the file and keeps the entry, which moves to TVHeadend's Removed Recordings tab (Remove after import) |
dvr/entry/prevrec/set | Remove after import: marks the entry previously recorded, so it stays a completed recording across TVHeadend restarts and never re-records |
dvr/autorec/grid, dvr/autorec/create | Series recordings |
idnode/save, idnode/delete | Applying padding to a new entry; removing an autorec rule. Before remove after import: setting the entry's retention to Forever, and deleting a scheduled re-record of it |
dvr/config/grid | Finding the default DVR profile, cached after the first call |
epggrab/channel/grid | Channel logo fallback: the guide feed's channel icons, used when TVHeadend reports no icon_public_url for a channel. Admin only in TVHeadend; a failure leaves the logos to TVHeadend's own icons |
status/inputs, hardware/tree | Tuner count and signal readings for the dashboard; the multiplex each tuner carries, for the live TV preflight |
status/subscriptions | What holds each tuner, and why a live stream stalled |
mpegts/service/grid | The multiplex of every service, cached for 10 minutes |
service/streams | A channel's elementary streams and their PIDs, which set the live TV codec choice |
config/load | TVHeadend's default language, for the live TV audio track |
/stream/channel/<uuid> | Live TV. Not under /api; requested with profile=pass, weight=50, a Freetvarr-live/<session> user agent, and no timeout |
Two translations happen at this boundary:
- Series identity. TVHeadend has no single series ID, so Freetvarr synthesises one:
`${channelUuid}|${title.toLowerCase()}`. The UI uses that key to check whether a programme's series already has a series recording. - Season and episode.
dvr/entry/*returns a human-readableepisode_disp(Season 3.Episode 7) rather than numeric fields, soparseEpisodeDispregexes the numbers back out. Guide events, by contrast, carryseasonNumberandepisodeNumberdirectly.
Errors come back as a TvheadendError carrying a stage (the API path) and a code, so a failure logs where it happened. HTTP 401 and 403 are mapped to code: 'auth' and surfaced as a credentials problem rather than a generic HTTP error.
Import state machine
The sync engine lists dvr/entry/grid_finished and saves each entry's details (channel, air time, synopsis) on its recordings row. It matches the entry to a series folder (a shows row) by case-insensitive substring, longest pattern first, and runs it through this decision tree:
- Library decision.
libraryDecisionis a pure function, exported for tests. A choice saved on the row (library_choice, set by the IMPORT button) wins. Next comes the choice made on RECORD, which TVHeadend keeps in the entry'scommentfield. Otherwise a recording that matches a series folder imports, and an unmatched recording imports to the one-off folder unlessimport_unmatchedisfalse. - Already-done short-circuit. A
recordingsrow withstatus='done', or one withdeleted_from_tvh_atset (not in TVHeadend), is skipped without touching the disk. - Path translation.
localPathForrewrites the filename TVHeadend reported fromtvh_recordings_pathtorecordings_root. A filename outside that prefix returnsnulland the row is writtenskippedwith the reason, because the container cannot see that path. - Existence check. A
fs.statfailure means the recording is still running, or failed, or the mount is wrong. The row goesskippedwith the path in the error. - Import.
buildDestPathcomposes<media_root>/<dest_folder>/<season dir>/<filename>; for a recording with no series folder,buildOneOffPathcomposes<oneoff_root>/<title>/<title> - <date> <time>.ts. Both throw if the result resolves outside their root, and a missing one-off root givesskipped, so nothing lands inside the container's own filesystem. The file is then hardlinked or copied (below). - Classify.
classifyImport({ expectedSize, actualSize })is a pure function, exported for tests. A shortfall over1MBagainst the size TVHeadend reported givespartialwith the byte gap in the error, and the row counts as a failure in the sync summary, not a skip. Anything else givesdone.
A statusText from TVHeadend other than Completed OK is recorded on the row even when the import succeeded, so a recording with data errors from a weak signal says so.
Each sync also saves the programme image of every scheduled recording to <config>/artwork/recordings/<uuid>, because guide image links often expire once the programme airs. Channel logos are saved to artwork/channels/ whenever one loads. Artwork leaves with its row; an orphan file older than 14 days is pruned.
The sync itself is single-flight: a module-level inFlight holds the running sync's ID, and a second request returns that ID rather than starting a second pass.
Why the import is a hardlink
importFile tries fs.link first and falls back to fs.copyFile on any error. A hardlink is a second name for the same bytes: instant, no extra disk, and both names stay valid until the last one is removed. TVHeadend's copy is deleted once Plex confirms the file, so a second name is all the import needs.
The fallback exists because a hardlink cannot cross filesystems, and Linux also refuses one between two bind mounts (EXDEV), even when both mounts show one disk. Docker makes each volumes: entry a separate bind mount. A container that mounts recordings/ and media/tv as two entries copies every import, a multi-gigabyte transport stream that takes minutes. The example compose file mounts ${DATA_PATH} once, at /data, so both folders sit inside one mount and every import is a link.
The Doctor Hardlinks check makes a real test link from the recordings folder into the media folder and the one-off folder. It reports one disk with separate mounts as a different case from separate disks.
An import whose destination already exists at exactly the source size returns early, so a re-run does nothing.
Series recordings as autorec rules
The SERIES button creates one TVHeadend autorec rule through dvr/autorec/create:
{
"enabled": true,
"title": "<show title>",
"fulltext": false,
"channel": "<channel uuid>",
"start": "Any",
"start_window": "Any",
"weekdays": [1, 2, 3, 4, 5, 6, 7],
"pri": 2,
"record": 1,
"start_extra": 2,
"stop_extra": 10,
"maxcount": "<episodes to keep>",
"comment": "freetvarr"
}The design decisions in that payload:
- Title plus channel, any time, any day. A time window would silently miss episodes whenever a broadcaster moves the timeslot.
fulltext: falsekeeps the match on the title field rather than the synopsis, which otherwise catches every programme that mentions the show. record: 1is TVHeadend's duplicate detection by episode number. Where the XMLTV feed supplies episode numbers this stops repeats cleanly. Where it doesn't, every airing records and Freetvarr's own already-done check catches the repeats.start_extra: 2/stop_extra: 10are minutes of padding. The end gets more because free-to-air programmes overrun far more often than they start early.comment: "freetvarr"tags every entry and rule Freetvarr created, so entries made by hand in TVHeadend's own UI are distinguishable.
Single recordings take a second call. dvr/entry/create_by_event does not accept padding, so Freetvarr creates the entry, then patches start_extra and stop_extra onto it with idnode/save.
Remove after import
TVHeadend's duplicate check (_dvr_duplicate_event in src/dvr/dvr_db.c) compares a new autorec entry with the DVR entries TVHeadend still holds. It skips entries in the MISSED_TIME or NOSTATE state and failed recordings, but it counts a successful recording whose file is gone. The duplicate check therefore needs the entry, not the file. removeRecordings in src/tvheadend.js makes these calls for each finished recording, in this order:
idnode/deleteon each upcoming entry whoseparentis this recording. A DVR profile with a re-record error limit schedules such a child when a recording has too many data errors. For a child that has not started, this is the same asdvr/entry/cancel: TVHeadend destroys the child and sets the parent'snorerecord. For a child already recording, it also aborts the recording and deletes the partial file.dvr/entry/cancelwould leave that child linked to the parent, and TVHeadend's re-record logic can then delete the parent entry.idnode/savewithretention: 2147483647(Forever) on the entry.dvr/entry/removedestroys the entry together with the file when the retention isOn file removal(dvr_entry_delete_retention_expired), and that is the default DVR profile setting.dvr/entry/prevrec/set. This setsnoresched,norerecord, andfileremoved, and completes the entry with thepreviously recordedcode. Withoutnoresched, the next TVHeadend start turns a completed entry with no files intoMISSED_TIME(dvr_entry_set_timer), and the duplicate check then ignores it.dvr/entry/remove. TVHeadend deletes the file and keeps the entry ingrid_removed.
The order sets the failure mode. If a call fails part-way, the file stays on disk and TVHeadend still holds the entry, so the duplicate check still sees the episode. idnode/delete on a finished entry is never used: it deletes the entry together with the file, and the next autorec update schedules the next repeat of the episode.
Stop a recording
STOP RECORDING keeps or deletes the part already recorded, by how long the entry has recorded. src/web/stop-recording.js holds the rule for the server and the dialog text: STOP_RECORDING_KEEP_MIN_MS is 5 minutes. The recording start is the later of the entry's start_real and its create time, because an entry made part-way through a programme has a start_real before it existed. stopRecordingEntry in src/tvheadend.js then makes these calls:
| Recorded | Entry from | Calls |
|---|---|---|
5 minutes or more | Any | dvr/entry/stop. The entry stays a finished recording, and the next sync imports it |
Under 5 minutes | A series recording | dvr/entry/stop, then the last three steps of Remove after import |
Under 5 minutes | A one-off recording | dvr/entry/stop, then dvr/entry/remove |
A series episode keeps its entry in both cases, so TVHeadend's duplicate check still sees it and the autorec does not schedule that airing again.
CANCEL SERIES with STOP THE EPISODE ON NOW lists the autorec's entries that are recording, deletes the autorec with idnode/delete, then stops each listed entry. The list comes first because deleting the autorec unlinks its running entries. TVHeadend cancels the autorec's scheduled entries itself and keeps the running ones, which are one-offs by the time Freetvarr stops them. With KEEP TITLE MATCH off, the same request deletes the shows row last.
Ad removal
Free-to-air recordings carry their commercial breaks. Plex has no marker API for non-DVR library items and doesn't read EDL sidecar files, so for Plex, Freetvarr cuts the breaks out of the file. Kodi reads an EDL sidecar, so detect mode also writes one for Kodi to skip. It spawns two binaries (comskip for detection, ffmpeg/ffprobe for cutting; both in the Docker image, not npm deps), and every step assumes detection is sometimes wrong.
The feature is double-gated: a global ad_removal_enabled setting (SETTINGS → AD REMOVAL, default off) and a per-show mode (off / detect / cut, default off). Processing runs inline in the sync loop, immediately after an import classifies done (never on partial), and a failure never stops the sync or damages the recording.
Detect runs comskip against the file with an EDL output forced on (the resolved ini is copied to a temp file with output_edl=1 appended; last key wins in comskip, so a user-supplied ini can't silently disable it), parses the EDL, and stores the breaks as JSON on the recordings row (ad_status='detected', ad_breaks_json). The video is untouched. syncSkipMarkers writes the breaks to <name>.edl beside the video, one start end 3 row per break: action 3 is Kodi's commercial break (skipped once, can be rewound into), and the Jellyfin EDL plugins map 3 to a Commercial segment. Action 0 would be a hidden cut in Kodi, so it is never written. A cut or no_breaks result deletes that .edl, because its times no longer match the video; a failed scan leaves it. This mode exists so users can audit comskip's accuracy on their channels before letting it cut anything.
Cut continues from detection:
ffprobereads the container duration;computeKeepSegmentsinverts the merged, clamped break list into keep segments. An empty keep list (breaks covering the whole file) is treated as a failure; Freetvarr never produces an empty output.- Each keep segment is extracted with
ffmpeg -ss … -to … -c copy, mapping only the video, audio and subtitle streams (-map 0:v -map 0:a -map 0:s?). AU DVB-T recordings carry a private data stream the mpegts muxer can't stream-copy, so a blanket-map 0aborts the cut; dropping that stream is harmless for playback. Keyframe stream-copy, no transcode; output stays.ts, then the segments are concatenated with ffmpeg's concat demuxer. All intermediate files live in a hidden.freetvarr-adcut/workdir next to the recording: same filesystem, so the final swap is an atomic rename, and hidden so Plex ignores it. The workdir is removed on every exit path. - Verify then swap: the output must be non-empty and its ffprobe duration must match the summed keep-segment duration within a tolerance that scales with boundary count (
max(5, 2 × boundaries)seconds; keyframe snapping costs up to a couple of seconds per cut point). Only then does the swap happen: original →<file>.ts.orig, output → original name; if the second rename fails the.origis rolled back. Plex ignores the unknown.origextension. - Any caught failure at any step leaves the original file in place and marks the row
cut_failed; the sync carries on. The one gap the rollback can't cover is a process death between the two renames of the swap; that would leave no file at the real path.recoverInterruptedCutson startup closes it: for any recording row whosefile_pathis missing on disk but whose<file>.ts.origexists, it renames the.origback, so the next start repairs a crash mid-swap.
NOTE
The cut rewrites the file in your library, not the TVHeadend copy. TVHeadend's own entry still points at its original file, and deleting through dvr/entry/remove removes that one.
Sync housekeeping deletes .orig backups after ad_original_retention_days (default 7). Until then, renaming the .orig back undoes a bad cut.
Remove gating: for a cut-mode show, the TVHeadend copy is the last pristine source once the local file has been rewritten. Remove after import is therefore only queued when the cut verified (or no breaks were found); a cut_failed or detect-only outcome keeps the TVHeadend copy. This composes with the Plex-refresh guard (delete_after_plex_refresh_only, default on), which skips the remove entirely when a Plex refresh was attempted and failed.
comskip.ini resolution: Freetvarr bundles assets/comskip.ini, tuned for Australian free-to-air DVB-T (detection method, break-length windows, brightness/silence thresholds, logo detection). Detect-mode runs on Network 10 captures (July 2026) found the expected pattern (five 2.5–4-minute ad blocks per ~75-minute episode, plus short pre-roll and tail slivers); other channels are untested. If comskip.ini exists in the config dir (the /config bind mount), it wins. GET /api/settings reports which one is active, and Settings shows it.
Manual scans: POST /api/recordings/:recording_id/ad-scan (re)processes an already-imported recording using the show's mode (detect-only when the show is off), so existing files can be trialled without re-importing. The endpoint responds 202 immediately and processes in the background; the UI polls the recordings list for the resulting ad_status. Both entry points (this endpoint and the sync loop's inline call) share one single-flight guard, a module-level Set keyed by recording ID inside processRecordingAds. A given recording is therefore only ever processed by one comskip/ffmpeg pipeline at a time; the endpoint returns 409 if that recording is already in flight. Different recordings may still process concurrently.
Comskip is CPU-bound; expect roughly half an hour for a 75-minute 1080i broadcast .ts (~2.7GB) on NAS-class hardware. The scan timeout scales with the recording's duration (1.5× realtime, 60-minute floor, 6-hour cap) so a long recording isn't killed mid-scan and misreported as producing no EDL. It runs under nice -n 10 so a scan does not slow a concurrent import. Per-recording statuses (scanning, detected, no_breaks, cut, detect_failed, cut_failed) surface on the RECORDINGS tab. A scan interrupted by a restart leaves no stuck scanning row: startup resets any in-flight status back to unscanned.
Live progress indicators
An ad scan, a cut, and a cross-filesystem import can each take minutes. src/progress.js keeps an in-memory progress registry that these operations write to and GET /api/recordings merges into each row. Nothing is persisted and there is no WebSocket; the RECORDINGS tab polls faster while an operation runs.
The registry is a module-level Map keyed by recording ID, shared by every request handler and the sync loop because it's all one Node process. Entries are ephemeral, present only while an operation runs:
{
phase: 'importing' | 'scanning' | 'cutting' | 'verifying',
percent: number | null, // 0..100, or null when the phase is indeterminate
etaSeconds: number | null,
etaLabel: string | null, // preformatted with pretty-ms; the SPA has no build step
detail: string | null, // '12.4MB/s', 'segment 2/5', '18s elapsed'
startedAt: number,
updatedAt: number,
}setProgress(recordingId, patch) shallow-merges and stamps updatedAt; clearProgress deletes; getProgress returns the entry or null if it's missing or older than PROGRESS_STALE_MS (30 s), deleting it on staleness; snapshotProgress(recordingIds) collects the fresh entries for the API merge. The staleness check covers a process that dies before its cleanup runs: the API stops serving a leaked entry after 30 seconds. The server preformats rate strings and ETA durations, because the SPA has no bundler to format them.
The three sources:
- Import:
makeImportProgress(recordingId)runs on the copy path only, because a hardlink is instant. A1 sticker stats the growing destination file and computes percentage, rate, and ETA against the source size. - Cut:
cutBreaksalready loops over keep segments, so it writes acuttingentry withsegment i/Nper segment and switches toverifyingbefore the duration check. Stream-copy takes seconds per segment, so neither phase carries a percentage. - Scan:
comskipruns through a bufferedexecFilePand gives no live output, so the percentage is an estimate from the recording's duration.detectBreaksalready probes duration (for the timeout); a 1-second ticker writespercent = clamp(0, 99, elapsed / expectedScanMs × 100)whereexpectedScanMs = durationSeconds × SCAN_REALTIME_FACTOR × 1000.SCAN_REALTIME_FACTORis0.5, above the~0.39measured on the author's NAS, so the estimate runs slow rather than fast. It clamps at 99 while comskip runs, including when the scan over-runs. If the duration probe failed, the entry haspercent: nulland shows elapsed time.computeScanPercentandexpectedScanMsare pure exports, unit-tested.
Cleanup is in one place per source. The scan ticker clears its own setInterval in a finally inside detectBreaks; processRecordingAds clears the registry entry in a single finally around the whole detect/cut sequence; the import shim clears on stop(). On the API side, GET /api/recordings calls snapshotProgress over the returned rows and attaches progress (the entry or null) to each one without mutating the query result.
The RECORDINGS tab shows each entry as a progress bar with percent and ETA. Its poll is a self-scheduling setTimeout that picks the next interval from the rows it just fetched: RECORDINGS_ACTIVE_POLL_MS (2 s) when any row has a non-null progress, otherwise RECORDINGS_POLL_MS (60 s). After a restart no stale progress remains: the registry starts empty and resetInterruptedScans clears any persisted scanning row.
Guide days and history
TVHeadend deletes a guide event once it ends, so on its own the guide has nothing before now. Freetvarr saves every event it fetches into the guide_history table (src/guide-history.js, migration 0002), and the scheduler fetches the guide at most once an hour so history builds even when nobody has the app open. Rows that ended before the start of yesterday are pruned on each save. GET /api/epg/guide merges saved past events with TVHeadend's live ones (live data wins on overlap) and marks each event past; recorded means the event had a DVR entry when it was last seen, not that the recording succeeded.
Each guide day is built from calendar dates in Freetvarr's time zone (guideDayWindow in src/epg.js, client helpers in src/web/guide-time.js), so a daylight-saving day is 23 or 25 hours long and its ruler labels stay right. The window runs from local midnight to 3am the next day (spillEnd); a programme that crosses midnight comes back once, at full width. At midnight the client reloads TODAY. test/guide-day.test.js covers the Sydney changes on 2026-10-04 and 2027-04-04.
Live TV
The browser plays live TV as HLS that Freetvarr makes itself. TVHeadend streams MPEG-TS, which no browser plays, and TVHeadend's own transcoding profiles vary by build, so Freetvarr uses the ffmpeg already in its image.
Start (POST /api/live):
- Check the channel id against the guide's channel list.
- Run the preflight (below). A
409withcode: 'no-tuner'lists what holds each tuner. - Read the channel's first service from
service/streamsand pick the streams withpickStreams.h264Planhandles H.264 video: Chrome cannot decode interlaced H.264, so it is deinterlaced and re-encoded to progressive H.264 (LIVE_TV_TRANSCODE=copykeeps the old copy).detectLiveEncoderinsrc/live-encoder.jsruns once at startup (liveEncoderReadyinsrc/server.js). Forautoandhardwareit checksLIVE_TV_VAAPI_DEVICE(default/dev/dri/renderD128) is readable and writable, then runs a test encode throughdeinterlace_vaapiandh264_vaapi(low-power first, then full). A pass selects VAAPI: deinterlace only interlaced frames,720-line cap,-qp 24. Otherwise it selects software (yadif,libx264,540-line cap) and logs the reason on a[live] videoline. NVENC is not supported. MPEG-2, HEVC, and other video goes tolibx264(veryfast,zerolatency, CRF23, GOP50), withyadifand a576-line cap for SD and a540-line cap for HD. Audio is the first track withaudio_type0(so never audio description), preferring TVHeadend's default language, always re-encoded to stereo AAC at128k. Streams are mapped by PID. When the service has both aTELETEXTand aTEXTSUBstream, the plan gainsteletext: { pid, page }: theTELETEXTPID, and the pageTEXTSUB PID - 0x2000(8993gives801; anything outside100–899falls back to801). - Spawn ffmpeg reading
-f mpegts -i pipe:0with a short probe (-probesize 500000 -analyzeduration 500000), so ffmpeg starts writing after about1 sinstead of the default5 s. It writes-hls_time 2with-hls_init_time 1(the first segments are1 slong) and-hls_list_sizeset byliveSegmentCount(the buffer minutes in2-second segments, never fewer than6), plusdelete_segments,independent_segments,omit_endlist, andtemp_file, so a reader never sees a half-written playlist. Thelibx264and VAAPI paths add-force_key_frames 'expr:lt(t,4)*gte(t,n_forced)'so the first segments cut on a keyframe every second. Copy mode cuts on the broadcast's own keyframes.-muxdelay 0keeps the segment timestamps in line with the caption timestamps, and-max_interleave_delta 500000stops a silent caption stream from holding back segments. ffmpeg writes a master playlistindex.m3u8(-master_pl_name,-var_stream_map) over the media playlistlive.m3u8. With teletext, ffmpeg decodes the page (-txt_page <page> -txt_format text -txt_duration 4000) to WebVTT, and writeslive_vtt.m3u8andlive<N>.vttbeside the video segments. The server renames the caption track toEnglishand addsAUTOSELECT=YESin the master. If ffmpeg exits before the stream is live, the session restarts it once for each fallback and reopens the TVHeadend stream: with the default probe when its error says it lacks stream parameters (Could not find codec parameters,unspecified sample rate,unspecified size), and without teletext when its error names the teletext PID. - Open the TVHeadend stream through the same
sendAuthenticatedpath as the API, with anAbortSignal, and pipe the body into ffmpeg's stdin. The credentials never appear in ffmpeg's arguments or in any URL.
Sessions are one per channel and shared by every viewer of it, up to LIVE_TV_MAX_SESSIONS (default 2). The playlist request waits up to 15 s for the first segments, so the player asks for it straight after the start request returns. If that wait fails, the player falls back to polling the preflight until the session is live. The libx264 and VAAPI paths serve the first segment, because it opens on an encoder keyframe; copy mode leaves it out of the playlist, because it starts mid-GOP at the tuner cut-in. With hls.js, the player keeps the video paused under the tuning animation until 3 s are buffered ahead of the playhead (or 4 s after the stream's metadata loads), so playback does not stall when the 1 s opening segments give way to 2 s ones. hls.js holds 4 s behind the newest segment (liveSyncDuration), and GO LIVE returns to that point. Every playlist or segment request refreshes the session's last-seen time, and so does the player's preflight poll. A reaper runs every 5 s:
- Idle: no request for
20 sends the session, unless the session is held (below). - Stalled: no bytes from TVHeadend for
10 smarks it stalled, thenstatus/subscriptionsdecides why. A DVR subscription that started after the stream means a recording preempted it. The stream's own subscription still present means no input. Neither means TVHeadend dropped it. - ffmpeg exit: the first line ffmpeg printed to stderr becomes the stop reason.
Teardown runs in a fixed order: abort the upstream request, SIGTERM ffmpeg (SIGKILL after 3 s), then delete the session folder. Server shutdown tears down every session, and startup deletes any folder a crash left behind. An ended session's reason stays readable for 60 s, so the player can say why it stopped.
The preflight (GET /api/live/preflight?channel=<uuid>) reads status/inputs live and compares multiplex names. A tuner already on the channel's multiplex is shared; otherwise an idle tuner is needed. For recordings whose padded start falls in the next 60 minutes, it counts the distinct multiplexes needed at that start: recordings running then, that recording, and this stream. More than the tuner count is a conflict, returned with the recording's title and start. Recordings come from the 45 s recording-state cache, so the player's poll every 10 s costs TVHeadend only a few small status reads.
The player is one Vue component mounted once in the app shell and teleported to <body>, so playback continues across tab changes. It uses hls.js wherever Media Source Extensions exist and recovers a capped number of media errors. It uses native HLS only where they do not exist (iPhone Safari). It imports hls.js only when a stream starts: /vendor/hls.mjs is served from node_modules at a pinned version, with its worker at /vendor/hls.worker.js. iOS allows playback only from a tap, so the tap handler calls video.play() on the empty element at once and sets the source when the stream is ready. When the page becomes hidden (a locked phone, another app, another tab) it sends a keepalive POST /api/live/:session/hold with the CSRF header. A hold sets heldUntil to now plus the buffer length, and the reaper skips the idle check until then; with no buffer, a hold acts as a leave. On pagehide the page sends DELETE unless the page is going into the back-forward cache (event.persisted), in which case it sends a hold. When the page becomes visible again it polls at once, and that poll refreshes the last-seen time.
Pause and rewind work inside the playlist window. The session view carries bufferSeconds, and the player turns its controls off when it is 0. The player measures how far behind live it is once a second (timeupdate stops while paused), against hls.js's liveSyncPosition, or 6 s before the seekable end on native HLS. A seeking handler moves any seek before the oldest kept segment to 4 s after it, because ffmpeg has deleted the older files. hls.js runs with liveMaxLatencyDuration set to Infinity, so it never jumps a paused viewer back to live. On touch screens, touchend on the player frame detects taps (under 10 px of movement and 300 ms); a double-tap in the left or right 40% of the picture, above the native control bar, skips 10 s, and further taps on that side within 0.7 s add 10 s each. Safari on an iPhone plays native HLS and shows no timeline for a live stream, so double-tap is its only way to rewind. hls.js through ManagedMediaSource would give a timeline, but hls.js then sets disableRemotePlayback, which turns AirPlay off.
Mobile layout
The UI has one breakpoint: Tailwind's md (768 px). Below it is the phone layout (tested against a 390×844 pt iPhone); at or above it is the desktop layout.
The deck-table views use dual markup on a phone: the desktop <table> sits behind hidden md:table, and a deck-card list sits behind md:hidden in the same Vue template. Each row is one item (title, metadata, actions), so it fits a card. A CSS-only data-label table transform was rejected because cells full of buttons, selects, and headerless action columns lose their order. The table and the cards share the same helpers (summary-line, labels, fmtTime/fmtBytes) and the progress-block component. On a phone there is no column sorting (the server-side order applies) and no title tooltips; the ad break count and minutes move into the card, and the other tooltips are dropped.
Below md, filter button groups become chip-rows: one line that scrolls sideways. The tab nav uses the same pattern (tab-strip), and a watch(route) calls scrollIntoView to keep the active tab visible.
Below md, top-level .panels and the TV Guide's .epg-scroll take a -1rem side margin and drop their side borders, so they span the screen. The header, tab nav, footer, and nested panels keep their padding and borders.
The rest is iOS Safari fixes in styles.css:
.field-inputbumps to16 pxbelowmd; iOS auto-zooms (and stays zoomed) on focusing any input smaller than that.viewport-fit=coverin the meta tag plusenv(safe-area-inset-bottom)on the settings save bar and footer, so the home indicator doesn't cover the SAVE button;min-h-dvhinstead ofmin-h-screenfor the collapsing URL bar.- Every
:hoverrule is scoped inside@media (hover: hover); otherwise taps leave sticky hover states. - Under
@media (pointer: coarse), buttons and checkboxes grow toward Apple's44 pthit target. A latermax-width: 767pxblock shrinks them again, because those sizes were too large on a real390 pxphone. - Selected chips use
.btn-on, andbtn-primarymarks actions only, so state and action never share a style. fmtTimeputs a non-breaking space between date and time, so a list datetime never wraps.-webkit-tap-highlight-color: transparent, since controls have their own pressed states.
Full environment reference
The TVHeadend URL and credentials, the Plex token, and the storage paths are runtime settings; configure them in the web UI (or the first-run wizard), not via env. The env vars below are deploy/runtime knobs only.
NOTE
MEDIA_ROOT, ONEOFF_ROOT, RECORDINGS_ROOT, TVH_RECORDINGS_PATH, and PLEX_PREFS_PATH also act as defaults for matching DB-backed settings that can be overridden from the UI at runtime. The fallback chain is settings DB value → env var → hardcoded default. The Storage panel in Settings and the wizard's STORAGE step show the effective values and can test each path.
| Variable | Notes |
|---|---|
MEDIA_ROOT | Default for the media_root setting: the directory Freetvarr writes imported episodes to. Defaults to /media/tv; the example compose file sets /data/media/tv. |
ONEOFF_ROOT | Default for the oneoff_root setting: the directory for recordings with no series folder. Defaults to /media/one-offs; the example compose file sets /data/media/one-offs. |
MOVIES_ROOT | Default for the movies_root setting: the directory for films with no series folder, named Title (Year). Empty by default, which sends films to the one-off folder. |
RECORDINGS_ROOT | Default for the recordings_root setting: where Freetvarr sees TVHeadend's recordings inside its own container. Defaults to /recordings; the example compose file sets /data/recordings. |
TVH_RECORDINGS_PATH | Default for the tvh_recordings_path setting: the path prefix TVHeadend reports in the filenames it hands out. Defaults to /recordings, which is the path in the TVHeadend container. Freetvarr rewrites this prefix to RECORDINGS_ROOT. |
DB_PATH | Absolute path to the SQLite state file. Defaults to <repo>/config/state.db; compose sets it to /config/state.db so state lives on the bind mount. |
PORT | HTTP port inside the container. Defaults to 3733. |
NODE_ENV | production selects production behaviour in Express and the dependencies. Compose sets this. |
TZ | Optional IANA timezone. Pre-fills the wizard's zone and applies until a zone is chosen; a zone stored in Freetvarr's settings wins over it. It also applies to TVHeadend when set. Freetvarr uses the zone chosen in the wizard (stored as a setting, changeable in the SCHEDULE panel), which drives guide days, recording file dates, and the sync schedule. TVHeadend mounts the host's /etc/localtime read-only and follows the host zone; Freetvarr does not use that mount, because Node needs a zone name. The Dockerfile installs tzdata so any IANA zone resolves. /api/settings exposes the effective value as tz; the web UI renders all timestamps in that zone. |
PUID/PGID | Runtime UID/GID (set via compose user:). Defaults to 1000:1000. Must match TVHeadend's, because Freetvarr hardlinks and deletes files TVHeadend created. |
CSRF_SECRET | Optional override for the secret that signs the CSRF cookie. When unset, Freetvarr generates a random secret on first start and saves it to csrf-secret in its config folder (/config/csrf-secret in the container, readable only by the Freetvarr user); later starts reuse it. |
TVH_URL | Optional. When set, AUTO-DISCOVER TVHEADEND offers this address instead of probing. The stored tvh_url setting still wins once saved. |
LIVE_TV_MAX_SESSIONS | How many channels live TV streams at once. Defaults to 2. Each channel holds a tuner unless it shares a multiplex with a recording or another stream. |
LIVE_TV_BUFFER_MINUTES | Minutes of live TV kept for pause and rewind. Defaults to 30; 0 keeps only the last 12 seconds; capped at 120. ffmpeg keeps that many minutes of 2-second HLS segments in the session folder. See Live TV. |
LIVE_TV_TRANSCODE | How live TV handles H.264: auto (default), hardware, software, or copy. See Live TV. |
LIVE_TV_VAAPI_DEVICE | Render device for hardware encoding. Defaults to /dev/dri/renderD128. |
Compose-only env (set in .env alongside docker-compose.yml):
| Variable | Notes |
|---|---|
FREETVARR_PORT | Host port the container binds (under network_mode: host, also flows into PORT inside the container). Defaults to 3733. |
CONFIG_PATH | Optional, default ./config. Host root for the config bind mounts. Freetvarr's /config is ${CONFIG_PATH}/freetvarr; TVHeadend's is ${CONFIG_PATH}/tvheadend. |
DATA_PATH | Optional, default ./data. Host root for the data. Holds only recordings/ and media/. Freetvarr mounts it whole at /data; TVHeadend mounts ${DATA_PATH}/recordings at /recordings. |
PLEX_PREFS_PATH | Optional. Host path to Plex's Preferences.xml, bind-mounted read-only so the AUTO-DETECT TOKEN button can read PlexOnlineToken. Drop the mount if Plex is on a different computer. |
Docker deployment
- Compose pulls the Freetvarr image from
ghcr.io/furey/freetvarr; no clone is needed to run it. TVHeadend comes fromlscr.io/linuxserver/tvheadend. Contributors build from a clone withdocker-compose.dev.yml. - The Dockerfile inlines
npm ci --omit=dev --ignore-scripts && npm run rebuild:nativesinstead of callingnpm run setup, so the build skipsnpm audit signatures. That step re-queries the registry and enforces.npmrc'smin-release-age=3, which would block whenever a brand-new dep is in the lockfile. Runnpm run setupon the host once the newest dep has aged past the threshold; the lockfile's integrity hashes still verify package contents duringnpm ci. - Both services use
network_mode: host(noports:mapping). TVHeadend needs it to discover a network tuner such as an HDHomeRun, which announces itself by UDP broadcast that doesn't traverse Docker's bridge network. Side-effect: neither container is on a Docker bridge network, so they address each other by the host's LAN IP rather than by container name, and so does anything else that wants to reach them. - Both run as
${PUID}:${PGID}(default1000:1000). They must match: Freetvarr hardlinks files TVHeadend wrote and deletes them afterwards. - A one-shot
initservice (busybox) runs first. It makes the subfolders ofCONFIG_PATHandDATA_PATHand gives them toPUID:PGID; both other services wait for it withservice_completed_successfully. Some Docker builds (Synology's among them) refuse to mount a host folder that does not exist, soCONFIG_PATHandDATA_PATHthemselves must exist first;install.shmakes them, and standard Docker creates them itself. tiniis PID 1 inside the Freetvarr container soSIGTERMpropagates cleanly.- The image skips dev dependencies (the Tailwind CLI), because
src/web/tailwind.cssis committed. - The Docker healthcheck hits
GET /healthzevery30 s. - The container entrypoint (
docker-entrypoint.sh) runsknex migrate:latestagainst/config/state.dbbefore exec'ing the server, so pending migrations apply on the next start and a fresh host needs no manual migration. - Compose pulls
ghcr.io/furey/freetvarr:latest. Each release tag builds the image natively foramd64andarm64, boots it, and publishes it asX.Y.Zandlatest(.github/workflows/image.yml).docker compose pull && docker compose up -dupdates; the bind-mounted/config/state.dbis untouched. - To build from a checkout instead, add
docker-compose.dev.yml:docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build freetvarr.
Volumes:
| Service | Container path | Host path | Purpose |
|---|---|---|---|
tvheadend | /config | ${CONFIG_PATH}/tvheadend | TVHeadend's own configuration, channels, and DVR entries |
tvheadend | /recordings | ${DATA_PATH}/recordings | Where TVHeadend writes .ts files |
freetvarr | /config | ${CONFIG_PATH}/freetvarr | SQLite state DB and an optional comskip.ini override |
freetvarr | /data | ${DATA_PATH} | Recordings at /data/recordings, the TV library at /data/media/tv, and the one-off folder at /data/media/one-offs. One mount makes the import a hardlink |
freetvarr | /plex-preferences.xml (ro) | ${PLEX_PREFS_PATH} | Optional. Read-only, only for the Auto-detect token button |
freetvarr | /plex (ro) | ${CONFIG_PATH}/plex | The plex profile's settings folder, read-only, for the Auto-detect token button. Empty without the profile |
plex | /config | ${CONFIG_PATH}/plex | plex profile only. Plex's settings and database |
plex | /data/media | ${DATA_PATH}/media | plex profile only. The library folders, at the same paths Freetvarr uses |
IMPORTANT
Freetvarr must see the recordings and the media folders through one mount. Linux refuses a hardlink between two bind mounts, so separate mounts make every import a full copy. Keep only recordings/ and media/ in ${DATA_PATH}, because Freetvarr can write to all of it.
Security model
Vulnerability reporting and the accepted residual risks are documented in SECURITY.md. The hardening measures:
.npmrcsetsignore-scripts=true(never run lifecycle scripts during install; native rebuilds are explicit),engine-strict=true,audit-level=high,save-exact=true,package-lock=true, andmin-release-age=3(refuse packages newer than three days, which limits exposure to newly published malicious versions).npm run setupusesnpm ci(notnpm install) so deps come straight from the lock file.npm run rebuild:nativesis an explicit allow-list; onlybetter-sqlite3rebuilds. Adding a new native dep means adding it here on purpose.npm audit signaturesruns as the last step ofsetupto verify the npm registry signatures of every dep. The Docker build inlinesnpm ci + rebuild:nativesinstead, becausenpm audit signaturesre-queries the registry and enforcesmin-release-age.package-lock.jsonis committed; integrity hashes verify package contents duringnpm cieven when the audit step is skipped.- HTTP: Helmet with a strict CSP.
script-src 'self' 'unsafe-eval'is required because Vue's in-browser template compiler usesnew Function(); everything else is locked down. Dropping'unsafe-eval'would need a build step that pre-compiles templates.media-src 'self' blob:andworker-src 'self' blob:let the live TV player attach hls.js's MediaSource to the video element and run its worker. - Live TV files:
GET /api/live/:session/:fileaccepts a 16-hex-digit session id and a file name matchingindex.m3u8,live.m3u8,live_vtt.m3u8,seg<N>.ts, orlive<N>.vttonly, so no request can reach outside the session folder. Starting a stream is rate limited like the guide's record endpoints; starting and stopping need the CSRF token. - Rate limiting:
express-rate-limiton the POST endpoints that reach TVHeadend or Plex (/api/sync,/api/tvh-shows,/api/tvh-test,/api/tvh-detect,/api/tvh-recordings-path-check,/api/discover-plex, the per-recording delete and ad-scan endpoints), and a separate limiter on the guide's record/cancel endpoints. - CSRF:
csrf-csrf(double-submit cookie) protects state-changing POSTs. The UI fetches a token fromGET /api/csrf-tokenand sends it as thex-csrf-tokenheader.generateTokenis called withoverwrite=trueso a stale browser cookie from a previousCSRF_SECRETdoesn't trigger a 403 mint. The secret isCSRF_SECRETwhen set, otherwise the contents of thecsrf-secretfile in the config folder, created at first start; if the folder is not writable, the server exits with a message to check thatCONFIG_PATHis owned byPUID:PGID. Deleting the file generates a new secret at the next start.getSessionIdentifieris a constant, because this is an authless LAN service. The front-end clears the cached token and retries once on any 403, so a rotated secret or a cleared cookie needs no user action. - Path containment:
dest_folderandseason_templateare validated on write to reject..segments and leading slashes, andbuildDestPathresolves the final path and throws if it escapes the media root. A show can only ever write inside the configured library. - Credential storage: the TVHeadend password sits in plaintext in the SQLite database, and
GET /api/settingsreturns only atvh_password_setboolean rather than the value. There is no login, which is why Freetvarr is LAN-only. - Generated secret: with no
CSRF_SECRET, each install generates its own random secret into thecsrf-secretfile, so no two installs share one. A terminal Express error handler returns the error message only, never a stack trace. - Daemon resilience: a
process.on('unhandledRejection')handler logs and keeps the process alive, so a DB-layer rejection in an async route handler can't take the whole daemon (and any in-flight sync or cut) down. - Indexing:
X-Robots-Tag: noindex, nofollowis set globally; this is a LAN-only service. - HSTS disabled: Freetvarr serves over plain HTTP on the LAN. Helmet's default
Strict-Transport-Securityheader would tell browsers to refuse HTTP for the host for a year, which is wrong for this deployment. Re-enable HSTS (with an appropriatemaxAge) only when fronted by TLS.
Local development
git clone https://github.com/furey/freetvarr
cd freetvarr
cp .env.example .env # optional; all values are overrides
npm run setup # ci --ignore-scripts + rebuild natives + audit signatures
npm start # http://localhost:3733; first visit shows the setup wizard
# (prestart auto-creates ./config/ and runs migrations)npm start / npm run dev load ./.env via Node's --env-file-if-exists, so any CSRF_SECRET or TZ you set there applies to the from-source run; without them, Freetvarr generates its own secret and uses the zone chosen in the wizard. The Docker path doesn't use .env.example at all; it takes its environment from the compose file.
better-sqlite3 is pinned to a release with prebuilt binaries for Node 24, so npm run setup downloads one rather than compiling. On a Node version with no prebuilt binary it compiles from source, which needs Python 3, make, and a C++ compiler.
Running from source still needs a reachable TVHeadend. Point the tvh_url setting at an existing instance on the LAN; nothing else about the host matters, because Freetvarr talks to it over HTTP like any other client.
NOTE
npm run setup calls npm audit signatures, which honours .npmrc's min-release-age=3. If a dep in the lockfile was published in the last three days, the audit step fails (ETARGET notarget). Either wait for it to age past the threshold or run npm install --ignore-scripts --min-release-age=0 once for the freshly-published dep.
TIP
package.json's volta block pins Node and npm to the versions the Dockerfile uses. Install Volta and it'll auto-switch when you cd into the repo, which avoids EBADENGINE from the host npm and ABI mismatches on better-sqlite3.
For dev:
npm run dev # node --watch
npm run migrate:refresh # drop the SQLite DB and re-migrateProject layout
freetvarr/
├── src/
│ ├── server.js # Express app; helmet, CSP, rate limit, CSRF, X-Robots-Tag
│ ├── db.js # Knex instance + simple settings get/set
│ ├── tvheadend.js # The only module that speaks TVHeadend's JSON API
│ ├── epg.js # Guide + recording-state caching, channel prefs, series projection
│ ├── guide-history.js # Saves guide events TVHeadend will drop; prunes before yesterday
│ ├── folder-matcher.js # Fuse.js wrapper that scans the media root
│ ├── sync.js # Sync engine; list finished, match shows, hardlink or copy, persist; exports classifyImport / matchShow / buildDestPath / localPathFor for tests
│ ├── commercials.js # Ad removal; comskip detect + ffmpeg cut orchestration, pure helpers exported for tests
│ ├── live-tv.js # Live TV; stream picking, ffmpeg arguments, tuner preflight, session registry
│ ├── progress.js # In-memory progress registry + import-bar shim; merged into GET /api/recordings
│ ├── scheduler.js # node-cron wiring, reloads on settings change
│ ├── plex.js # Plex section refresh + token detection from Preferences.xml
│ └── web/ # Static UI (app.js, guide-time.js calendar-day helpers); Vue 3 SPA (browser ESM) + Tailwind v4 (prebuilt tailwind.css), hash-routed, responsive at md/768px, no bundler
├── test/ # node --test; no extra test deps
├── assets/
│ └── comskip.ini # Bundled AU free-to-air comskip tuning; /config/comskip.ini overrides
├── migrations/ # knex migrations
├── knexfile.js # Honours DB_PATH env (defaults to ./config/state.db)
├── Dockerfile # node:22-bookworm-slim + tini + comskip + ffmpeg + healthcheck
├── docker-entrypoint.sh # `knex migrate:latest` then `exec node src/server.js`
├── docker-compose.example.yml # Init, TVHeadend, and Freetvarr; download as docker-compose.yml
├── docker-compose.hwaccel.example.yml # Optional VAAPI override
├── install.sh # Downloads the compose file, writes .env, starts it
├── docker-compose.dev.yml # Override that builds Freetvarr from the checkout
├── .env.example # Local-dev minimal envs (optional CSRF_SECRET override and TZ default, PUID/PGID)
├── .npmrc # Supply-chain hardening
└── package.jsonNOTE
src/web/tailwind.css is built from src/tailwind.css (the @theme colour and font tokens) by npm run build:css, which scans src/web for class names. The file is committed, and npm test fails when it is stale, so run npm run build:css after you add a Tailwind class.
src/web/styles.css is a plain, unlayered stylesheet, while Tailwind emits its utilities inside @layer utilities. Unlayered rules beat layered ones no matter the specificity, so a bare element selector in styles.css (a { color: #62cfff }) overrides any Tailwind colour utility applied to an <a>. To restyle a link, add a class to styles.css or move the utility onto an inner <span>.
Scripts
| Script | What it does |
|---|---|
npm run setup | npm ci --ignore-scripts → npm run rebuild:natives → npm audit signatures |
npm run rebuild:natives | Explicitly rebuilds the native deps allow-listed in package.json (just better-sqlite3) |
npm run migrate | mkdir -p config && knex migrate:latest; idempotent, auto-run by start / dev |
npm run migrate:refresh | rm -f ./config/state.db && mkdir -p config && knex migrate:latest; dev only |
npm start | npm run migrate && node --env-file-if-exists=.env src/server.js (.npmrc sets ignore-scripts, so a prestart hook would never run) |
npm run dev | npm run migrate && node --env-file-if-exists=.env --watch src/server.js |
npm run build:css | Rebuilds src/web/tailwind.css from src/tailwind.css with the Tailwind CLI; run it after you add or remove a Tailwind class |
npm test | node --test 'test/*.test.js'; Node 24 built-in runner, no extra deps |
npm run test:integration | Starts a disposable linuxserver/tvheadend container with Docker and runs the TVHeadend bootstrap against it; never touches a real TVHeadend |
Testing
npm testNode 24's built-in test runner, with no additional test dependencies. Unit tests cover:
- Sync:
classifyImportacross the size-tolerance boundary,matchShow(case-insensitive substring matching),buildDestPath({season}/{season_padded}/{season_unpadded}substitution, missing-season fallback, media-root escape rejection),episodeFilename(theSxxEyyand air-date forms, and title sanitisation), andlocalPathFor(prefix rewrite, the outside-the-prefixnull, trailing-slash handling). - Ad removal: EDL parsing (malformed rows, action filtering), the
.edlskip file (path, action3rows, write on detect, delete on cut or no breaks, keep on failure), keep-segment maths (clamping, merging, break-at-edge, whole-file-break), cut verification tolerance, comskip.ini resolution, the auto-delete gating matrix, and the scan-estimate maths. - Progress: registry round-trip, staleness eviction past
PROGRESS_STALE_MS(via mocked timers),clearProgress, and the import shim's percentage and monotonically decreasing ETA. - Live TV:
pickStreams(audio description skipped, AC-3-only audio, H.264 deinterlaced and re-encoded (or copied underLIVE_TV_TRANSCODE=copy), MPEG-2 and HEVC transcoded with the right height cap), exact ffmpeg arguments with no URL or credentials,tunerVerdict(multiplex sharing, idle tuner, none free, recording conflict inside and outside the window, same-multiplex recording),stallReason, the file-name pattern against path traversal, and the session registry with a fake clock, fake ffmpeg, and fake upstream: teardown order, theSIGKILLfallback, sharing, the session limit, the idle reaper, the stall watchdog, and the ffmpeg error line. - Library routing:
libraryDecision(series folder, the RECORD choice, the row choice), longest-patternmatchShow, one-off and film paths, film detection, and the stored-path move when a root changes. - Artwork and playback: the artwork store (safe ids, orphan pruning, image shrinking) and the recording player (path guard, resume rule, seek-or-restart decision, the shared stream limit).
- TVHeadend bootstrap: fresh-instance detection, the ordered plan, abort and rollback before the open entry is removed, and undo.
npm run test:integrationruns the same flow against a real, disposable TVHeadend container. - Folder matcher: a real on-disk fixture under
os.tmpdir()exercisinglistShowFoldersandmatchShowFolderagainst realistic disambiguated folder names.
The TVHeadend client and the comskip/ffmpeg orchestration are tested by hand against real instances, not mocks. Manual smoke test:
npm run dev, hithttp://localhost:3733.- First visit (with empty settings): auto-redirects to the setup wizard. Walks the time zone (WELCOME; pre-filled from the browser, or from
TZwhen it is set) → TVHeadend (URL, user, TEST CONNECTION; SAVE & NEXT runs the same test and stays on the step until it passes) → storage (three paths, with TEST PATH and CHECK TVHEADEND) → Plex → ready. - Re-open the wizard later: REOPEN WIZARD in the HELP panel near the end of Settings. Saved values prefill; stored passwords and tokens show as stored, not as their values.
- Settings: the section bar jumps to each panel and
#/settings/<id>links scroll to one (aboutopens HELP); save; a Sync schedule change reloads the scheduler on save; TEST CONNECTION reports the TVHeadend version, channel count, and tuner count; the Plex buttons each succeed when Plex is reachable. - TV Guide: seven days of programmes with names; record, cancel, record-series, cancel-series each reflected in TVHeadend's own UI within a refresh; favourite, hide, and reorder channels; the Live TV page lists every channel's now and next.
- Series: the list joins the series recordings in TVHeadend to their folders; per-series SYNC on each row; pressing a row opens it, and there ASSIGN FOLDER, the folder form, UNASSIGN FOLDER, PAUSE, RESUME, and CANCEL SERIES each take effect; ADD TITLE suggests a folder from the effective
media_root. - Syncs: run a sync, watch the row appear and finish; page through the history
50rows at a time; clear history; filter by activity type. - Recordings: pressing a row opens its details and buttons; a cross-filesystem copy shows progress and the list polls every
2 s; a hardlink import shows none. A recording no longer in TVHeadend showsNOT IN TVHEADENDand can still be played, re-scanned, and re-cut; CLEAR FROM LIST hides those rows and leaves the files; UNDO restores them for10 safter. - Reset:
RESET FREETVARR(in the RESET panel, outside the settings form) callsPOST /api/reset, which deletes thesettings,shows,recordings, andsyncsrows and reloads into the wizard. Guide history and the media files stay.
Screenshots
The PNGs under docs/img/ are captured from the running app by scripts/capture-screenshots.sh. The script pulls the official Playwright Docker image (no host install required), drives headless Chromium across the tabs at desktop width plus a 390×844 mobile viewport, and writes the screenshots back into docs/img/ with the right ownership.
# the container must be up + reachable at the URL the script expects
./scripts/capture-screenshots.sh
# Re-shoot a single tab:
./scripts/capture-screenshots.sh settingsThe captures themselves are configured in scripts/capture-screenshots.mjs (desktop viewport 1280×936, mobile 390×844, viewport-only clip so every shot has the same aspect ratio, 2× device-scale). Before the Settings shot, the script rewrites the /api/settings response in-page so no real TVHeadend URL, password, Plex token, or host path reaches a committed PNG.
TIP
To shoot UI changes that haven't shipped yet, run them locally against a copy of the live database; every panel renders from SQLite and the settings row, so the shots match production. Copy the state file off the deploy host (ssh <host> 'cat /path/to/freetvarr/state.db' > /tmp/shots.db; scp fails on hosts with a restricted sftp subsystem, Synology among them), start the server with DB_PATH=/tmp/shots.db node src/server.js, then point the capture script at your machine's LAN IP rather than localhost (the Playwright container can't reach the host loopback). Cautions: the scheduler starts with the copied sync_cron, so capture outside the cron window or a real sync fires against the live TVHeadend, and delete the copy afterwards; it holds the TVHeadend password and the Plex token.
Documentation site
The published docs at furey.github.io/freetvarr are a VitePress site under docs/, deployed to GitHub Pages by .github/workflows/docs.yml on every push to main that touches docs/** (pull requests build but don't deploy). It's decoupled from the app: docs/ has its own package.json and lockfile, so cd docs && npm install && npm run dev previews it and npm run build compiles to docs/.vitepress/dist. The base is /freetvarr/; this file is surfaced on the site at /deep-dive via a rewrites entry, and the docs/guide/ pages are the user-facing companion to this reference.
One VitePress quirk: the > [!NOTE] alert convention in these files carries a trailing <br> (for the VS Code preview). VitePress would render that as a blank alert title, so docs/.vitepress/config.mjs strips it in a markdown hook.