| Protocol | Port | Purpose |
|---|---|---|
| TCP | 5004 (default) |
Main HTTP API, lineup, streams, admin UI/API |
| UDP | 65001 |
HDHomeRun discovery listener |
| UDP | 1900 (optional) |
UPnP/SSDP discovery responder (M-SEARCH + optional NOTIFY) |
| TCP | 80 (optional) |
Legacy client compatibility listener |
- UDP discovery replies are emitted only for compatible tuner discovery requests.
- Discovery responses include:
DeviceIDDeviceAuth- tuner count
BaseURLLineupURL
- If
HTTP_ADDR_LEGACYis set, discovery advertises that legacy port. BaseURLhost is selected from the local route toward the requesting client.- If
UPNP_ENABLED=true, the service also listens for SSDPM-SEARCHon UDP1900and responds for:ssdp:allupnp:rootdeviceuuid:<derived from DeviceID>urn:schemas-upnp-org:device:MediaServer:1urn:schemas-upnp-org:device:Basic:1urn:schemas-atsc.org:device:primaryDevice:1.0(HDHomeRun-oriented compatibility target)urn:schemas-upnp-org:service:ConnectionManager:1urn:schemas-upnp-org:service:ContentDirectory:1
- SSDP parser accepts both
M-SEARCH * HTTP/1.1and legacyM-SEARCH * HTTP/1.0request lines. - UPnP responses advertise
LOCATION: http://<host>:<port>/upnp/device.xmlusing the same host/port selection behavior as HDHR discovery. GET /device.xmlis a compatibility alias to the same UPnP device description payload.GET /upnp/scpd/connection-manager.xmlandGET /upnp/scpd/content-directory.xmlreturn live SCPD XML (no UI redirect behavior).POST /upnp/control/connection-managerandPOST /upnp/control/content-directoryexpose a bounded read-only SOAP action subset for interoperability:ConnectionManager:GetProtocolInfo,GetCurrentConnectionIDs,GetCurrentConnectionInfoContentDirectory:GetSearchCapabilities,GetSortCapabilities,GetSystemUpdateID,Browse
mDNS is optional and not required for core compatibility.
See deploy/avahi/README.md for optional .local helper setup.
| Method | Path | Description |
|---|---|---|
GET |
/discover.json |
Device metadata for discovery |
GET |
/lineup.json |
Published-channel lineup JSON |
GET |
/lineup.xml |
Published-channel lineup XML |
GET |
/lineup.m3u |
Published-channel lineup M3U |
GET |
/lineup_status.json |
Scan status compatibility stub |
GET |
/lineup.html |
Redirects to /ui/ |
GET |
/upnp/device.xml |
UPnP root device-description XML (LOCATION target for SSDP) |
GET |
/device.xml |
Compatibility alias for /upnp/device.xml |
GET |
/upnp/scpd/connection-manager.xml |
UPnP ConnectionManager SCPD XML |
GET |
/upnp/scpd/content-directory.xml |
UPnP ContentDirectory SCPD XML |
POST |
/upnp/control/connection-manager |
SOAP control endpoint (ConnectionManager action subset) |
POST |
/upnp/control/content-directory |
SOAP control endpoint (ContentDirectory action subset, including read-only Browse) |
GET |
/auto/v{GuideNumber} |
Stream channel by guide number |
GET |
/healthz |
Health check |
GET |
/metrics |
Prometheus metrics (when enabled) |
Notes:
- Stream routing also accepts
/auto/{guide}and normalizes a leadingv. - Lineup URLs are built from enabled published channels only.
- HDHomeRun lineup endpoints (
/lineup.json,/lineup.xml,/lineup.m3u) accept?show=demoand return an empty lineup for compatibility with HDHomeRun demo probing behavior. - HDHomeRun endpoints set
Connection: closeon responses to match physical-device behavior. Clients that poll metadata endpoints frequently should expect one TCP connection per request instead of keepalive reuse. - Unsupported or invalid UPnP SOAP actions return protocol-valid SOAP faults (instead of HTTP redirects).
All routes below are protected by Basic Auth when ADMIN_AUTH is configured.
| Method | Path | Description |
|---|---|---|
GET |
/ui/ |
Redirects to /ui/catalog |
GET |
/ui/catalog |
Catalog browsing with toolbar-driven dynamic channel creation, multi-group filtering, and rapid source-add target-channel mode |
GET |
/ui/channels |
Split channel control plane: traditional channels (100-9999) with reorder/DVR mapping controls, plus dynamic channel block management (10000+) |
GET |
/ui/channels/{channelID} |
Channel detail and source management |
GET |
/ui/dynamic-channels/{queryID} |
Dynamic block detail: block config + generated-channel sorting controls |
GET |
/ui/merge |
Duplicate suggestions grouped by channel_key |
GET |
/ui/tuners |
Live tuner/session status, tuned source, connected subscribers, per-session recovery trigger actions, plus a history master-detail console with status/recovery filters and tabbed session diagnostics |
GET |
/ui/automation |
Automation settings, schedules, and manual job triggers |
GET |
/ui/dvr |
DVR provider configuration, mapping, and sync actions |
GET |
/api/admin/playlist-sources |
List all playlist sources ordered by order_index |
POST |
/api/admin/playlist-sources |
Create a new playlist source (auto-generates source_key) |
GET |
/api/admin/playlist-sources/{sourceID} |
Get a single playlist source |
PUT |
/api/admin/playlist-sources/{sourceID} |
Update a playlist source |
DELETE |
/api/admin/playlist-sources/{sourceID} |
Delete a playlist source (blocked for source_id=1) |
GET |
/api/groups |
Paged playlist groups metadata (optional count suppression, optional source_ids filter) |
GET |
/api/items |
Filtered catalog items |
GET |
/api/channels |
List traditional published channels (100-9999) including per-channel source summary fields (source_total, source_enabled, source_dynamic, source_manual) |
POST |
/api/channels |
Create published channel from item_key with optional dynamic_rule |
PATCH |
/api/channels/{channelID} |
Update channel (guide_name, enabled, optional dynamic_rule) |
PATCH |
/api/channels/reorder |
Reorder complete channel list (204 No Content on success); successful reorder enqueues a coalesced DVR lineup reload (debounce=20s, max_wait=300s) |
DELETE |
/api/channels/{channelID} |
Delete channel |
GET |
/api/dynamic-channels |
List dynamic channel blocks (paged metadata response) |
POST |
/api/dynamic-channels |
Create dynamic channel block |
GET |
/api/dynamic-channels/{queryID} |
Read one dynamic channel block |
PATCH |
/api/dynamic-channels/{queryID} |
Update dynamic channel block config |
DELETE |
/api/dynamic-channels/{queryID} |
Delete dynamic channel block |
GET |
/api/dynamic-channels/{queryID}/channels |
List generated channels for one dynamic block |
PATCH |
/api/dynamic-channels/{queryID}/channels/reorder |
Reorder generated channels within one dynamic block (204 No Content on success); successful reorder enqueues a coalesced DVR lineup reload (debounce=20s, max_wait=300s) |
GET |
/api/channels/{channelID}/sources |
List channel sources |
POST |
/api/channels/{channelID}/sources |
Add source by item_key |
POST |
/api/channels/{channelID}/sources/health/clear |
Clear health/cooldown state for one channel's sources |
PATCH |
/api/channels/{channelID}/sources/{sourceID} |
Update source (enabled) |
PATCH |
/api/channels/{channelID}/sources/reorder |
Reorder channel sources (204 No Content on success) |
DELETE |
/api/channels/{channelID}/sources/{sourceID} |
Delete source |
POST |
/api/channels/sources/health/clear |
Clear health/cooldown state for all channel sources |
GET |
/api/suggestions/duplicates?min=2&q=cnn.us&limit=100&offset=0 |
Paged duplicate catalog suggestions grouped by channel_key (optional case-insensitive search across channel_key and tvg_id) |
GET |
/api/admin/tuners |
Runtime tuner/session snapshot including per-source virtual_tuners summaries, shared-session subscriber mappings, bounded session_history timelines, process-lifetime drain_wait / probe_close telemetry counters, and optional reverse-DNS client host resolution via resolve_ip |
POST |
/api/admin/tuners/recovery |
Trigger manual shared-session recovery for an active channel (channel_id, optional reason) |
GET |
/api/admin/automation |
Current automation state (playlist_url, playlist_sources, schedule config, analyzer settings, next/last run) |
PUT |
/api/admin/automation |
Update automation schedule/timezone/analyzer and playlist URL settings |
POST |
/api/admin/jobs/playlist-sync/run |
Trigger async playlist sync run (optional ?source_id=N for per-source sync) |
POST |
/api/admin/jobs/auto-prioritize/run |
Trigger async auto-prioritize run |
POST |
/api/admin/jobs/auto-prioritize/cache/clear |
Clear cached stream metrics used by auto-prioritize |
GET |
/api/admin/jobs/{runID} |
Fetch one job run (running, success, error, canceled) |
GET |
/api/admin/jobs?name=dvr_lineup_sync&limit=20&offset=0 |
List recent job runs by optional name filter (playlist_sync, auto_prioritize, dvr_lineup_sync) |
GET |
/api/admin/dvr |
Current DVR integration config, cached lineups, and last sync summary |
PUT |
/api/admin/dvr |
Update DVR config (active_providers, per-provider base URLs, default lineup, and sync schedule/mode). Primary provider is channels-only for sync/mapping workflows (provider must resolve to channels; non-channels values are rejected). Jellyfin supports optional jellyfin_tuner_host_id and write-only jellyfin_api_token inputs for post-sync reload fan-out. Legacy base_url remains supported as a channels base URL alias. |
POST |
/api/admin/dvr/test |
Verify DVR provider connectivity and device-channel visibility |
GET |
/api/admin/dvr/lineups?refresh=1 |
List DVR lineups (optional provider refresh) |
POST |
/api/admin/dvr/sync |
Run forward sync (hdhriptv mapping to DVR custom lineup patch). Optional JSON body (dry_run, optional include_dynamic); empty body is accepted and defaults to dry_run=false, include_dynamic=false. Execution is detached from initiating request cancellation and bounded by an internal timeout budget (2m default). |
POST |
/api/admin/dvr/reverse-sync |
Run reverse sync (provider custom mapping into hdhriptv channel mappings). Optional JSON body (dry_run, optional lineup_id, optional include_dynamic); empty body is accepted. Execution is detached from initiating request cancellation and bounded by an internal timeout budget (2m default). |
GET |
/api/channels/dvr |
Paged read of per-channel DVR mappings (used by /ui/channels; supports optional enabled_only=1, optional include_dynamic=1, plus limit/offset; dynamic generated channels are excluded by default) |
GET |
/api/channels/{channelID}/dvr |
Read one channel's DVR mapping |
PUT |
/api/channels/{channelID}/dvr |
Update one channel's DVR lineup/channel/station-ref mapping |
POST |
/api/channels/{channelID}/dvr/reverse-sync |
Reverse-sync one channel from DVR mapping state. Optional JSON body (dry_run, optional lineup_id); empty body is accepted. Execution is detached from initiating request cancellation and bounded by an internal timeout budget (2m default). |
Admin mutation routes that decode JSON bodies use strict single-object parsing:
- unknown JSON fields are rejected with HTTP
400 - trailing JSON content after the first object is rejected with HTTP
400 - oversized bodies are rejected with HTTP
413usingADMIN_JSON_BODY_LIMIT_BYTES - optional-body DVR sync routes (
/api/admin/dvr/sync,/api/admin/dvr/reverse-sync,/api/channels/{channelID}/dvr/reverse-sync) still enforce these rules when a non-empty body is provided
GET /api/items supports group, group_names, q, optional q_regex, limit, and offset query parameters:
group/group_namesandqare optional filters.- Group filter semantics:
- repeated
groupparameters are supported (?group=News&group=Sports) - comma-separated values are supported (
?group=News,Sports) group_namesis accepted as a compatibility alias- empty/omitted group filters mean all catalog groups
- repeated
qtoken semantics are case-insensitive substring match with OR-of-AND support:- include token:
fox - exclude token:
-spanishor!spanish - disjunct separators:
|or standaloneORkeyword (case-insensitive) - within each disjunct, include and exclude terms are AND-combined
- across disjuncts, clauses are OR-combined
- exclusion-only queries are allowed
- queries without OR separators keep legacy include/exclude AND behavior unchanged
- include token:
q_regexaccepts boolean values (1/0,true/false,yes/no,on/off) and defaults tofalse.- when
q_regex=false(default), token/LIKE behavior is unchanged. - when
q_regex=true,qis evaluated as one case-insensitive regex pattern against the full item name string (including spaces/punctuation). - regex mode does not apply token
|/ORor-term/!termquery-language operators; use raw regex syntax instead. - invalid regex patterns and overlong regex patterns are rejected with HTTP
400.
- when
/api/itemsresponse includes additivesearch_warningmetadata:mode(tokenorregex)truncated(bool)- effective limits (
max_terms,max_disjuncts,max_term_runes) - applied/dropped counters (
terms_applied,terms_dropped,disjuncts_applied,disjuncts_dropped) - rune truncation counter (
term_rune_truncations) - token-mode over-limit queries return
200withsearch_warning.truncated=true(visibility-first behavior).
- UI regex toggles are available on:
/ui/catalogsearch toolbar/ui/channelsdynamic block create/quick-edit flows/ui/channels/{channelID}dynamic rule editor/ui/dynamic-channels/{queryID}block detail editor
limitdefaults to100, values above1000are clamped to1000, and values<1are normalized back to100.offsetdefaults to0; negative values are normalized to0.- Non-integer
limit/offsetvalues are treated as defaults (100/0) rather than returning HTTP400.
Dedicated REST endpoints for playlist source management:
GET /api/admin/playlist-sources— list all playlist sources ordered byorder_index. Response:{"playlist_sources": [...]}.POST /api/admin/playlist-sources— create a new playlist source. Auto-generates an immutablesource_key(8-byte random hex for newly created sources; existing shorter legacy keys remain valid). Validates uniquename, uniqueplaylist_url, andtuner_count >= 1.source_idmust not be provided in the body. Response: the created source object.GET /api/admin/playlist-sources/{sourceID}— get a single playlist source. Returns404if not found.PUT /api/admin/playlist-sources/{sourceID}— update a playlist source. Accepts partial updates:name,playlist_url,tuner_count,enabled.source_keyis immutable and rejected. Updating the primary source'splaylist_urlalso mirrors the change to the legacyplaylist.urlsetting.DELETE /api/admin/playlist-sources/{sourceID}— delete a playlist source. Returns400forsource_id=1(primary source cannot be deleted). Source-owned catalog rows are removed (not reassigned), channel-source mappings for those rows are removed, generated dynamic channels keyed to deleted-source items are removed, and deletedsource_idvalues are pruned from dynamic-rule/query source filters. Returns404if not found.- Playlist-source mutations persist first, then reload runtime source pools. When runtime reload fails after persistence, the API returns HTTP
500with an explicit eventual-consistency payload:error=playlist_source_runtime_apply_failedoperation=<create_playlist_source|update_playlist_source|delete_playlist_source|update_playlist_sources>persisted=trueruntime_applied=falseconsistency=eventualruntime_error=<reload failure detail>- plus mutation scope metadata (
source_idorsource_ids) when available.
Source response fields: source_id, source_key, name, playlist_url, tuner_count, enabled, order_index, created_at, updated_at.
Validation error responses:
- Duplicate
name: HTTP400with field identification - Duplicate
playlist_url: HTTP400with field identification tuner_count < 1: HTTP400- Primary source deletion: HTTP
400
- Accepts optional
?source_id=Nquery parameter. - When present, validates that the source exists and is enabled; returns
404for nonexistent source,400for disabled source. - Scoped sync refreshes only the specified source (fetch + source-scoped upsert + reconcile + conditional DVR reload).
- When absent, syncs all enabled sources (existing behavior).
- Response includes
source_idwhen scoped.
- Accepts optional
source_idsquery parameter (comma-separated or repeated:?source_ids=1,2or?source_ids=1&source_ids=2). - When present, returns only groups from the specified playlist sources (deduplicated by name).
- When absent, returns groups from all sources (existing behavior).
- When
source_idsis requested against a backend that does not support source-scoped group paging, the API returns HTTP501with a structured contract payload:error:source_scoped_operation_unsupportedoperation:groups_listparameter:source_idsdetail: actionable backend-capability description
- Accepts optional
source_idsquery parameter (comma-separated or repeated). - When present, filters catalog items to only those from the specified playlist sources.
- When absent, returns items from all sources (existing behavior).
/api/admin/tunersincludes avirtual_tunersarray with per-source tuner pool summaries:- Each entry includes:
playlist_source_id,playlist_source_name,playlist_source_order,tuner_count,in_use_count,idle_count,active_session_count. - Sorted by
playlist_source_order, thenplaylist_source_id. - In single-pool mode, a single entry is synthesized for the primary source.
- During runtime source reconfigure drains, retained transitional source rows (including recently disabled/deleted sources with in-use leases) remain visible until lease drain completion.
- Each entry includes:
- Each tuner/session row and
client_streamsentry includes:playlist_source_id,playlist_source_name,virtual_tuner_slot. /api/admin/tunersand/ui/tunersintentionally redactsource_stream_urlvalues. The status payload preserves scheme/host/path but strips URL userinfo, query, and fragment fields./api/admin/tunerssupports optionalresolve_ipboolean query semantics:- accepted true values:
1,true,yes,on - accepted false values:
0,false,no,off - default is
false - malformed values return HTTP
400 - when
resolve_ip=true, the response populatesclient_hostfor:client_streams[*]session_history[*].subscribers[*]
- reverse lookups run per unique IP and are memoized within a single response payload.
- resolved hostnames (and lookup failures) are also memoized across requests via an in-process short TTL cache (
~2m). - each lookup is bounded by a per-lookup timeout (
2s) to avoid request-path stalls from long DNS waits. - the full resolve phase is bounded by a total request timeout budget (
8s). - lookups still execute sequentially in request scope; large numbers of unique client IPs can increase endpoint latency.
- reverse lookup failures are non-fatal and keep
client_hostempty for that address.
- accepted true values:
/api/admin/tunersincludes bounded history fields:session_history(newest-first),session_history_limit, andsession_history_truncated_count.session_historycombines active shared sessions plus recently closed sessions retained in-memory for diagnostics.- each history session includes lifecycle/recovery aggregates (
opened_at, optionalclosed_at,active,terminal_status,peak_subscribers,recovery_cycle_count,same_source_reselect_count) and nestedsources/subscriberstimelines. session_history.sources[*].stream_urluses the same sanitization policy as livesource_stream_urlfields.session_history.subscribers[*]capturesconnected_at, optionalclosed_at, andclose_reasonso disconnect timing can be correlated with upstream/client logs.session_history_limitreports active retention capacity (default256), whilesession_history_truncated_countreports how many oldest entries were evicted since process start.- each history entry also includes per-session timeline retention/truncation metadata:
source_history_limit,source_history_truncated_count,subscriber_history_limit, andsubscriber_history_truncated_count.
/ui/tunersrenders a bottomShared Session Historymaster-detail panel sourced fromsession_history, with deterministic row selection, status/errors/recovery filters, tabbed detail panes (Summary,Sources,Subscribers,Recovery), and a truncation banner when evictions have occurred./api/admin/tunerssnapshot fieldsrecovery_keepalive_mode,recovery_keepalive_fallback_count, andrecovery_keepalive_fallback_reasonreport active keepalive mode and fallback history for each shared session./api/admin/tunersalso exposes keepalive pacing/backlog telemetry for recovery windows:recovery_keepalive_started_at,recovery_keepalive_stopped_at,recovery_keepalive_duration,recovery_keepalive_bytes,recovery_keepalive_chunks,recovery_keepalive_rate_bytes_per_second,recovery_keepalive_expected_rate_bytes_per_second, optionalrecovery_keepalive_realtime_multiplier(when profile bitrate is known), and guardrail fieldsrecovery_keepalive_guardrail_count/recovery_keepalive_guardrail_reason.- When
RECOVERY_FILLER_MODE=slate_avand source profile dimensions are normalized, a debug log eventshared session slate AV recovery filler profile normalizedrecordsoriginal_resolution,normalized_resolution, and boundednormalization_reasontokens. POST /api/admin/tuners/recoverytriggers the same in-session recovery flow used by stall detection. Request body:{"channel_id":<id>,"reason":"optional"}; omitted/blankreasondefaults toui_manual_trigger.POST /api/admin/tuners/recoveryrequires at least one active subscriber on the target shared session; idle-grace sessions with zero subscribers return404(shared session not found) and do not start recovery churn.
/api/groups,/api/channels,/api/dynamic-channels,/api/channels/{channelID}/sources, and/api/dynamic-channels/{queryID}/channelsacceptlimit+offsetquery params and return paging metadata (total,limit,offset) in responses.- Group/channel/dynamic-query/source/generated-channel list endpoints default to
limit=200when omitted.limit=0is normalized to the same bounded default (200) instead of triggering unbounded/all-results reads. - Hard caps are applied when
limitis too large (/api/groups,/api/channels,/api/dynamic-channels, and/api/dynamic-channels/{queryID}/channelsmax1000,/api/channels/{channelID}/sourcesmax2000). - For those paged endpoints,
limitandoffsetmust be integers>= 0; negative or non-integer values return HTTP400(unlike/api/items, which normalizes non-integer values to defaults).
GET /api/groupssupports optionalinclude_countsboolean query semantics:- accepted true values:
1,true,yes,on - accepted false values:
0,false,no,off - default is
true - when
include_counts=false, group entries only includename(count metadata is omitted), which is useful for autocomplete consumers - malformed
include_countsvalues return HTTP400
- accepted true values:
POST /api/channels/{channelID}/sources/health/clearandPOST /api/channels/sources/health/clearreset persisted source health/cooldown fields (success_count,fail_count,last_ok_at,last_fail_at,last_fail_reason,cooldown_until) and return{"cleared":<count>}.
mindefaults to2, clamps to[2, 100].qperforms case-insensitive matching acrosschannel_keyandtvg_id.limitdefaults to100;limit=0also normalizes to100; values above500are clamped.offsetdefaults to0.limitandoffsetmust be integers>= 0; negative or non-integer values return HTTP400.- Legacy
tvg_idquery fallback is accepted whenqis omitted. - Response payload includes
total,limit, andoffset, and echoes normalizedminandqvalues.
GET /api/channels/dvraccepts optional:enabled_only(1,true,yes,on) to return only enabled-channel mappings.include_dynamic(1,true,yes,on) to include dynamic generated channels.limit/offsetpaging controls with strict integer parsing.limitdefaults to200; explicitlimit=0normalizes to the same bounded default.limitclamps at1000.offsetdefaults to0.- negative or non-integer
limit/offsetvalues return HTTP400.
- default behavior excludes dynamic generated channels.
Response payload includes
mappings,total,limit,offset, and echoes applied filters asenabled_onlyandinclude_dynamic.
GET /api/admin/dvr/lineupsaccepts optionalrefresh(1,true,yes,on) to force a provider lineup refresh; response payload echoes the applied boolean asrefresh.
DVR sync routes parse optional JSON payloads using empty-body-tolerant decoding:
POST /api/admin/dvr/syncPOST /api/admin/dvr/reverse-syncPOST /api/channels/{channelID}/dvr/reverse-sync- Empty body requests (including
Transfer-Encoding: chunkedwith no payload bytes) are accepted and use default request values. - Malformed JSON payloads return HTTP
400. include_dynamicdefaults tofalseon sync and reverse-sync APIs.- execution is detached from request cancellation after handler admission, and each run is bounded by an internal timeout budget (
2mdefault).
POST /api/channelsandPATCH /api/channels/{channelID}accept an optionaldynamic_ruleobject:enabled(bool)group_name(string, optional compatibility alias)group_names([]string, optional preferred multi-group filter contract)search_query(string, required whenenabled=true; token semantics matchGET /api/items?q=...whensearch_regex=false)search_regex(bool, optional; defaults tofalse)source_ids([]int, optional; playlist source filter — empty array means all sources)- when provided, the backend must support source-scoped catalog filtering for dynamic sync.
- unsupported backends return HTTP
501witherror=source_scoped_operation_unsupportedand operation identifiers (channel_dynamic_rule_createorchannel_dynamic_rule_update). - when
true,search_queryis treated as one case-insensitive regex pattern matched against the full item name. - token operators (
|/OR,-term/!term) are only applied whensearch_regex=false. - invalid regex inputs are rejected with HTTP
400before persistence.
- create/update responses include additive
search_warningmetadata fordynamic_rule.search_query, using the same warning fields as/api/items.
POST /api/dynamic-channelsandPATCH /api/dynamic-channels/{queryID}accept the same search filter contract:search_query(string, optional)search_regex(bool, optional; defaults tofalse)source_ids([]int, optional; playlist source filter — empty array means all sources)- when provided, the backend must support source-scoped dynamic-channel materialization.
- unsupported backends return HTTP
501witherror=source_scoped_operation_unsupportedand operation identifiers (dynamic_channel_query_createordynamic_channel_query_update). - regex-mode validation semantics match
/api/itemsand channel dynamic-rule validation.
- create/update/read/list responses include additive
search_warningmetadata per query row, so token-mode truncation is visible for persisted dynamic block queries.
- Channel create/update responses include normalized
dynamic_rulevalues. Dynamic-rule sync executes asynchronously in the background, so these requests are not blocked on catalog/source reconciliation. dynamic_rule.group_nameremains supported for legacy clients. When both fields are present, normalizedgroup_namessemantics are used andgroup_nameis treated as a compatibility alias of the first normalizedgroup_namesentry.GET /api/channelsexposes per-channel observability fields:source_total: total associated sourcessource_enabled: enabled associated sourcessource_dynamic: associations managed by dynamic query reconciliation (association_type=dynamic_query)source_manual: associations managed outside dynamic query reconciliation (association_type!=dynamic_query)
- Dynamic-rule background sync is detached from request cancellation and runs under an internal timeout budget per sync cycle; client disconnects after create/update do not cancel already-queued immediate sync work.
- When a newer enabled
dynamic_ruleupdate supersedes an in-flight immediate sync, the stale run is canceled/preempted and only the latest queued rule is applied. - Dynamic channel blocks reserve guide ranges in blocks of
1000starting at10000:block_start = 10000 + (order_index * 1000)- generated channels are capped at
1000entries per block - generated-channel reorder APIs reassign guide numbers deterministically within the block
- successful dynamic-block materialization/reorder changes enqueue a DVR lineup reload (coalesced queue, trailing edge,
debounce=20s,max_wait=300s) so provider-side lineup views converge without reload churn.
- DVR lineup reload queue behavior for reorder/materialization mutations:
- all lineup-changing admin mutation paths enqueue through one shared queue (
/api/channels/reorder, dynamic block materialization syncs, and/api/dynamic-channels/{queryID}/channels/reorder) - enqueue is trailing-edge debounced by
20s - every new enqueue extends the due time to
now + 20s, capped atfirst_enqueue + 300s - enqueue during an in-flight reload schedules exactly one follow-up debounced run
- queue execution is detached from request cancellation and uses an internal timeout budget (
30sdefault)
- all lineup-changing admin mutation paths enqueue through one shared queue (
GET /api/admin/jobsaccepts optionalname,limit, andoffsetquery parameters:namemust be empty or one ofplaylist_sync,auto_prioritize,dvr_lineup_sync; other values return HTTP400limitdefaults to50, clamps to[1, 500]; non-integer values fall back to the defaultoffsetdefaults to0and negative values are clamped to0; non-integer values fall back to the default- Response payload echoes normalized
name,limit, andoffsetvalues.
PUT /api/admin/automationaccepts partial JSON updates; omitted fields are left unchanged:- top-level:
playlist_url,timezone playlist_sourcesarray for bulk source updates (must include all existing sources withsource_id; validates unique names, unique URLs,tuner_count >= 1)- schedule objects:
playlist_syncandauto_prioritizewith optionalenabledandcron_spec - analyzer object:
probe_timeout_ms,analyzeduration_us,probesize_bytes,bitrate_mode,sample_seconds,enabled_only,top_n_per_channel - if a schedule resolves to
enabled=true,cron_specis required - analyzer validation rules:
probe_timeout_ms,analyzeduration_us,probesize_bytes, andsample_secondsmust be greater than0bitrate_modemust bemetadata,sample, ormetadata_then_sampletop_n_per_channelmust be>= 0
- top-level:
POST /api/admin/jobs/auto-prioritize/cache/clearreturns the number of deleted cached metric rows as{"deleted":<count>}.- Manual job triggers (
POST /api/admin/jobs/playlist-sync/run,POST /api/admin/jobs/auto-prioritize/run) are detached from request cancellation after enqueue; once202is returned, client disconnects do not cancel the run. PUT /api/admin/automationandPUT /api/admin/dvrare serialized under a shared admin-config mutation lock to prevent concurrent rollback clobber between automation and DVR config updates.PUT /api/admin/automationvalidates cron only for enabled schedules, then applies persistence/runtime reload/rollback under a detached30smutation context (context.WithTimeout(context.WithoutCancel(...))) so client disconnects do not cancel in-flight apply or rollback work.- Scheduler/timezone/analyzer setting runtime-apply failures are rolled back to the previous persisted settings.
playlist_sourcesbulk mutations are persisted atomically first; if playlist-source runtime reload fails afterwards, the response returns the explicit eventual-consistency contract (playlist_source_runtime_apply_failed) and persisted source changes remain in effect for subsequent retries.
PUT /api/admin/dvrvalidates enabled sync cron before persisting config. If scheduler apply fails after persistence, the previous DVR config is restored and the response reports rollback outcome.
# List catalog items
curl -u admin:change-me "http://127.0.0.1:5004/api/items?limit=20"
# Create a published channel from a catalog item
curl -u admin:change-me \
-H "Content-Type: application/json" \
-d '{"item_key":"src:news:primary"}' \
http://127.0.0.1:5004/api/channels
# Add a source with explicit cross-channel override
curl -u admin:change-me \
-H "Content-Type: application/json" \
-d '{"item_key":"src:backup:secondary","allow_cross_channel":true}' \
http://127.0.0.1:5004/api/channels/1001/sources
# Create a channel with an enabled dynamic rule
curl -u admin:change-me \
-H "Content-Type: application/json" \
-d '{"item_key":"src:news:primary","guide_name":"US News","dynamic_rule":{"enabled":true,"group_name":"US News","search_query":"news"}}' \
http://127.0.0.1:5004/api/channels
# Disable dynamic rule and cancel in-flight immediate sync
curl -u admin:change-me \
-X PATCH \
-H "Content-Type: application/json" \
-d '{"dynamic_rule":{"enabled":false}}' \
http://127.0.0.1:5004/api/channels/1001# List all playlist sources
curl -u admin:change-me http://127.0.0.1:5004/api/admin/playlist-sources
# Create a new playlist source
curl -u admin:change-me \
-H "Content-Type: application/json" \
-d '{"name":"Backup","playlist_url":"https://backup.example/playlist.m3u","tuner_count":2,"enabled":true}' \
http://127.0.0.1:5004/api/admin/playlist-sources
# Update a playlist source
curl -u admin:change-me \
-X PUT \
-H "Content-Type: application/json" \
-d '{"tuner_count":4}' \
http://127.0.0.1:5004/api/admin/playlist-sources/2
# Trigger per-source playlist sync
curl -u admin:change-me -X POST \
"http://127.0.0.1:5004/api/admin/jobs/playlist-sync/run?source_id=2"# Inspect automation state
curl -u admin:change-me http://127.0.0.1:5004/api/admin/automation
# Update schedules/timezone and analyzer probe settings
curl -u admin:change-me \
-X PUT \
-H "Content-Type: application/json" \
-d '{"timezone":"America/Chicago","playlist_sync":{"enabled":true,"cron_spec":"*/30 * * * *"},"auto_prioritize":{"enabled":false},"analyzer":{"bitrate_mode":"metadata_then_sample","sample_seconds":3,"enabled_only":true,"top_n_per_channel":0}}' \
http://127.0.0.1:5004/api/admin/automation
# Trigger playlist sync job
curl -u admin:change-me -X POST \
http://127.0.0.1:5004/api/admin/jobs/playlist-sync/run
# Clear auto-prioritize metrics cache
curl -u admin:change-me -X POST \
http://127.0.0.1:5004/api/admin/jobs/auto-prioritize/cache/clear{
"run_id": 42,
"status": "queued"
}{
"run_id": 42,
"job_name": "playlist_sync",
"triggered_by": "manual",
"status": "running",
"progress_cur": 4,
"progress_max": 27
}# Run forward DVR sync with defaults (no JSON body)
curl -u admin:change-me -X POST \
http://127.0.0.1:5004/api/admin/dvr/sync
# Run a dry-run forward DVR sync
curl -u admin:change-me \
-H "Content-Type: application/json" \
-d '{"dry_run":true}' \
http://127.0.0.1:5004/api/admin/dvr/sync
# Import a single channel mapping from DVR
curl -u admin:change-me -X POST \
-H "Content-Type: application/json" \
-d '{"dry_run":false,"lineup_id":"USA-MN12345-X"}' \
http://127.0.0.1:5004/api/channels/123/dvr/reverse-syncExample DVR sync response:
{
"dry_run": true,
"sync_mode": "configured_only",
"updated_count": 3,
"cleared_count": 0,
"unchanged_count": 14,
"unresolved_count": 1,
"warnings": [
"lineup=USA-MN12345-X station_ref=97047 has empty lineup channel; using station_ref-only mapping"
]
}ADMIN_AUTHempty:/ui/*and/api/*are open.ADMIN_AUTHmalformed (notuser:pass):/ui/*and/api/*return HTTP500.
401: missing/invalid admin credentials404: channel/source/item not found501: source-scoped operation requested against unsupported backend capability429: rate limit exceeded502: upstream or ffmpeg stream failure503: all tuners are busy, or channel tune backoff is active